From da8a82c78eb8f573d29fa42497cbfd2f90d85def Mon Sep 17 00:00:00 2001 From: Arc Date: Sat, 21 Feb 2026 13:48:27 +0000 Subject: [PATCH] black --- lnbits/core/helpers.py | 4 +- lnbits/core/views/extension_api.py | 117 ++++++++++++++++++++------ lnbits/core/wasm/extension_host.py | 117 +++++++++++++++++++++++--- lnbits/core/wasm/runner.py | 70 ++++++++++++++- lnbits/templates/pages/extensions.vue | 19 +++++ 5 files changed, 285 insertions(+), 42 deletions(-) diff --git a/lnbits/core/helpers.py b/lnbits/core/helpers.py index efef4827f..540da788a 100644 --- a/lnbits/core/helpers.py +++ b/lnbits/core/helpers.py @@ -82,8 +82,8 @@ def _is_wasm_extension(ext: InstallableExtension) -> bool: config_json = json.load(json_file) if config_json.get("extension_type") == "wasm": return True - except Exception: - pass + except Exception as exc: + logger.debug(f"Failed to load extension config for '{ext.id}': {exc!s}") return False diff --git a/lnbits/core/views/extension_api.py b/lnbits/core/views/extension_api.py index db88e8320..8b68fed71 100644 --- a/lnbits/core/views/extension_api.py +++ b/lnbits/core/views/extension_api.py @@ -22,12 +22,12 @@ from lnbits.core.models.extensions import ( Extension, ExtensionConfig, ExtensionMeta, + ExtensionPermission, + ExtensionPermissionsGrant, ExtensionRelease, ExtensionReview, ExtensionReviewPaymentRequest, ExtensionReviewsStatus, - ExtensionPermission, - ExtensionPermissionsGrant, InstallableExtension, PayToEnableInfo, ReleasePaymentInfo, @@ -182,56 +182,104 @@ async def api_enable_extension( 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) + _ensure_permissions(required_permissions, granted_permissions) + + if grant and grant.permissions: + await _store_granted_permissions(user_ext, granted_permissions) + + 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 + +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) ) - required_permissions = [p.id for p in permissions_source] if permissions_source else [] - granted_permissions = [] + 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: - granted_permissions = grant.permissions - elif user_ext.extra and user_ext.extra.granted_permissions: - granted_permissions = user_ext.extra.granted_permissions + return grant.permissions + if user_ext.extra and user_ext.extra.granted_permissions: + return user_ext.extra.granted_permissions + return [] - 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 enable this extension.", - ) - 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 _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 _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: @@ -248,9 +296,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.") @@ -302,7 +349,9 @@ async def api_update_extension_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 [] + required_permissions = ( + [p.id for p in permissions_source] if permissions_source else [] + ) granted_permissions = grant.permissions if grant and grant.permissions else [] if required_permissions: @@ -654,6 +703,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)): if ext.meta and ext.meta.permissions else [dict(p) for p in _load_permissions_from_config(ext.id)] ), + "kvSchema": _load_kv_schema_from_config(ext.id), "grantedPermissions": ( user_exts_map.get(ext.id).extra.granted_permissions if user_exts_map.get(ext.id) and user_exts_map.get(ext.id).extra @@ -701,6 +751,21 @@ def _load_permissions_from_config(ext_id: str) -> list[ExtensionPermission]: 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 {} + + @extension_router.get( "/reviews/tags", dependencies=[Depends(check_account_exists)], diff --git a/lnbits/core/wasm/extension_host.py b/lnbits/core/wasm/extension_host.py index 1861c4b2d..c7ce0dbea 100644 --- a/lnbits/core/wasm/extension_host.py +++ b/lnbits/core/wasm/extension_host.py @@ -1,12 +1,11 @@ from __future__ import annotations import asyncio - +import json import time from pathlib import Path import httpx -import json from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse, Response from fastapi.staticfiles import StaticFiles @@ -48,6 +47,59 @@ def _ensure_kv_table(db: Database, ext_id: str) -> str: return query +_kv_schema_cache: dict[str, dict] = {} + + +def _load_kv_schema(ext_id: str) -> dict: + if ext_id in _kv_schema_cache: + return _kv_schema_cache[ext_id] + try: + conf_path = Path( + settings.lnbits_extensions_path, "extensions", ext_id, "config.json" + ) + if not conf_path.is_file(): + _kv_schema_cache[ext_id] = {} + return _kv_schema_cache[ext_id] + with open(conf_path, "r+") as json_file: + config_json = json.load(json_file) + schema = config_json.get("kv_schema", {}) + if not isinstance(schema, dict): + schema = {} + _kv_schema_cache[ext_id] = schema + return schema + except Exception: + _kv_schema_cache[ext_id] = {} + return _kv_schema_cache[ext_id] + + +def _schema_for_key(schema: dict, key: str) -> dict | None: + if not schema: + return None + entry = schema.get(key) + return entry if isinstance(entry, dict) else None + + +def _coerce_schema_value(schema_entry: dict, value): + value_type = schema_entry.get("type", "string") + if value_type == "int": + return int(value) + if value_type == "float": + return float(value) + if value_type == "bool": + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in {"true", "1", "yes", "y"} + return bool(value) + if value_type == "json": + if isinstance(value, (dict, list)): + return value + if isinstance(value, str): + return json.loads(value) + raise ValueError("Invalid json value") + return str(value) + + async def _kv_get(db: Database, ext_id: str, key: str) -> str | None: await db.execute(_ensure_kv_table(db, ext_id)) table = f"{ext_id}.kv" if db.schema else "kv" @@ -56,6 +108,17 @@ async def _kv_get(db: Database, ext_id: str, key: str) -> str | None: {"key": key}, ) if not row: + schema = _load_kv_schema(ext_id) + entry = _schema_for_key(schema, key) + if entry and "default" in entry: + default_value = _coerce_schema_value(entry, entry.get("default")) + stored = ( + json.dumps(default_value) + if entry.get("type") == "json" + else str(default_value) + ) + await _kv_set(db, ext_id, key, stored) + return stored return None return row.get("value") @@ -88,12 +151,7 @@ async def _require_permission(user_id: str, ext_id: str, permission: str) -> Non raise HTTPException(403, f"Missing permission: {permission}") -def register_wasm_ext_routes(app, ext) -> None: - ext_id = ext.code - db = Database(f"ext_{ext_id}") - router = APIRouter(prefix=f"/{ext_id}", tags=[f"{ext_id} (wasm)"]) - proxy_block = f"/{ext_id}/api/v1/proxy" - +def _register_pages_routes(router: APIRouter, ext_id: str) -> None: @router.get("/", response_class=HTMLResponse) async def index(req: Request, user: User = Depends(check_user_exists)): try: @@ -114,6 +172,8 @@ def register_wasm_ext_routes(app, ext) -> None: except Exception: return HTMLResponse("Public page not found", status_code=404) + +def _register_kv_routes(router: APIRouter, ext_id: str, db: Database, ext) -> None: @router.get("/api/v1/kv/{key}") async def api_kv_get(key: str, user: User = Depends(check_user_exists)): await _require_permission(user.id, ext_id, "ext.db.read_write") @@ -139,6 +199,16 @@ def register_wasm_ext_routes(app, ext) -> None: value = payload.get("value") if value is None: raise HTTPException(400, "Missing value") + schema = _load_kv_schema(ext_id) + entry = _schema_for_key(schema, key) + if schema and not entry: + raise HTTPException(400, "Key not allowed by schema") + if entry: + try: + coerced = _coerce_schema_value(entry, value) + except Exception: + raise HTTPException(400, "Invalid value for schema") from None + value = json.dumps(coerced) if entry.get("type") == "json" else str(coerced) await _kv_set(db, ext_id, key, str(value)) await websocket_updater(f"{ext_id}:{key}", str(value)) return {"key": key, "value": value} @@ -159,6 +229,8 @@ def register_wasm_ext_routes(app, ext) -> None: await websocket_updater(f"{ext_id}:{key}", str(new_value)) return {"key": key, "value": new_value} + +def _register_watch_routes(router: APIRouter, ext_id: str, db: Database, ext) -> None: @router.post("/api/v1/watch") async def api_watch_payment(payload: dict, user: User = Depends(check_user_exists)): payment_hash = payload.get("payment_hash") @@ -199,11 +271,17 @@ def register_wasm_ext_routes(app, ext) -> None: finally: unregister_invoice_listener(queue_name) - asyncio.create_task(_watch()) - return {"ok": True} + task = asyncio.create_task(_watch()) + return {"ok": True, "task_id": id(task)} + +def _register_proxy_routes( + router: APIRouter, app, ext_id: str, proxy_block: str +) -> None: @router.post("/api/v1/proxy") - async def api_proxy(payload: dict, req: Request, user: User = Depends(check_user_exists)): + async def api_proxy( + payload: dict, req: Request, user: User = Depends(check_user_exists) + ): method = str(payload.get("method", "GET")).upper() path = str(payload.get("path", "")).strip() if not path.startswith("/") or "://" in path: @@ -233,7 +311,9 @@ def register_wasm_ext_routes(app, ext) -> None: media_type=resp.headers.get("content-type"), ) - static_dir = _ext_static_dir(ext_id, ext.upgrade_hash) + +def _mount_static(app, ext_id: str, upgrade_hash: str | None) -> None: + static_dir = _ext_static_dir(ext_id, upgrade_hash) if static_dir.is_dir(): app.mount( f"/{ext_id}/static", @@ -241,6 +321,19 @@ def register_wasm_ext_routes(app, ext) -> None: name=f"{ext_id}_static", ) + +def register_wasm_ext_routes(app, ext) -> None: + ext_id = ext.code + db = Database(f"ext_{ext_id}") + router = APIRouter(prefix=f"/{ext_id}", tags=[f"{ext_id} (wasm)"]) + proxy_block = f"/{ext_id}/api/v1/proxy" + + _register_pages_routes(router, ext_id) + _register_kv_routes(router, ext_id, db, ext) + _register_watch_routes(router, ext_id, db, ext) + _register_proxy_routes(router, app, ext_id, proxy_block) + _mount_static(app, ext_id, ext.upgrade_hash) + prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash else "" app.include_router(router, prefix=prefix) diff --git a/lnbits/core/wasm/runner.py b/lnbits/core/wasm/runner.py index 440df1bc3..0c753fac2 100644 --- a/lnbits/core/wasm/runner.py +++ b/lnbits/core/wasm/runner.py @@ -18,6 +18,54 @@ from wasmtime import ( from lnbits.db import Database from lnbits.settings import settings +_kv_schema_cache: dict[str, dict] = {} + + +def _load_kv_schema(ext_id: str) -> dict: + if ext_id in _kv_schema_cache: + return _kv_schema_cache[ext_id] + try: + conf_path = Path( + settings.lnbits_extensions_path, "extensions", ext_id, "config.json" + ) + if not conf_path.is_file(): + _kv_schema_cache[ext_id] = {} + return _kv_schema_cache[ext_id] + with open(conf_path, "r+") as json_file: + config_json = json.load(json_file) + schema = config_json.get("kv_schema", {}) + if not isinstance(schema, dict): + schema = {} + _kv_schema_cache[ext_id] = schema + return schema + except Exception: + _kv_schema_cache[ext_id] = {} + return _kv_schema_cache[ext_id] + + +def _schema_for_key(schema: dict, key: str) -> dict | None: + if not schema: + return None + entry = schema.get(key) + return entry if isinstance(entry, dict) else None + + +def _coerce_schema_value(schema_entry: dict, value: str): + value_type = schema_entry.get("type", "string") + if value_type == "int": + return int(value) + if value_type == "float": + return float(value) + if value_type == "bool": + if value.lower() in {"true", "1", "yes", "y"}: + return True + if value.lower() in {"false", "0", "no", "n"}: + return False + raise ValueError("Invalid bool value") + if value_type == "json": + return json.loads(value) + return value + def _run(coro): return asyncio.run(coro) @@ -62,7 +110,11 @@ def _db_get( key = _read_bytes(caller, key_ptr, key_len).decode(errors="ignore") _run(db.execute(_ensure_kv_table(db, ext_id))) table = f"{ext_id}.kv" if db.schema else "kv" - row = _run(db.fetchone(f"SELECT value FROM {table} WHERE key = :key", {"key": key})) + row = _run( + db.fetchone( + f"SELECT value FROM {table} WHERE key = :key", {"key": key} + ) + ) if not row: return 0 value = str(row.get("value", "")) @@ -82,9 +134,23 @@ def _db_set( ) -> int: key = _read_bytes(caller, key_ptr, key_len).decode(errors="ignore") value = _read_bytes(caller, val_ptr, val_len).decode(errors="ignore") + schema = _load_kv_schema(ext_id) + entry = _schema_for_key(schema, key) + if schema and not entry: + raise RuntimeError("Key not allowed by schema") + if entry: + try: + coerced = _coerce_schema_value(entry, value) + except Exception as exc: + raise RuntimeError("Invalid value for schema") from exc + value = json.dumps(coerced) if entry.get("type") == "json" else str(coerced) _run(db.execute(_ensure_kv_table(db, ext_id))) table = f"{ext_id}.kv" if db.schema else "kv" - row = _run(db.fetchone(f"SELECT key FROM {table} WHERE key = :key", {"key": key})) + row = _run( + db.fetchone( + f"SELECT key FROM {table} WHERE key = :key", {"key": key} + ) + ) if row: _run( db.execute( diff --git a/lnbits/templates/pages/extensions.vue b/lnbits/templates/pages/extensions.vue index 98a5d62da..fdcbff3af 100644 --- a/lnbits/templates/pages/extensions.vue +++ b/lnbits/templates/pages/extensions.vue @@ -954,6 +954,25 @@ +
+
KV keys defined:
+ + {{ key }} + +