From 42afbc05764fe817839d500d55d1d7cde75ad392 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 23 Jun 2026 13:37:30 +0300 Subject: [PATCH] basic extension loading --- lnbits/app.py | 14 +- lnbits/core/extensions/__init__.py | 9 ++ lnbits/core/extensions/loader.py | 196 ++++++++++++++++++++++++++++ lnbits/core/extensions/prototype.py | 91 +++++++++++++ lnbits/core/extensions/runtime.py | 113 ++++++++++++++++ lnbits/core/helpers.py | 8 ++ 6 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 lnbits/core/extensions/loader.py create mode 100644 lnbits/core/extensions/prototype.py create mode 100644 lnbits/core/extensions/runtime.py diff --git a/lnbits/app.py b/lnbits/app.py index 46caa188c..3fb0d4491 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -24,6 +24,11 @@ from lnbits.core.crud import ( update_installed_extension_state, ) from lnbits.core.crud.extensions import create_installed_extension +from lnbits.core.extensions.loader import ( + is_wasm_extension_dir, + is_wasm_extension_id, + register_wasm_extension, +) from lnbits.core.helpers import migrate_extension_database from lnbits.core.models.notifications import NotificationType from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions @@ -310,8 +315,9 @@ async def build_all_installed_extensions_list( # noqa: C901 installed_extensions.append(ext_info) await create_installed_extension(ext_info) - current_version = await get_db_version(ext_id) - await migrate_extension_database(ext_info, current_version) + if not is_wasm_extension_dir(ext_dir): + current_version = await get_db_version(ext_id) + await migrate_extension_database(ext_info, current_version) except Exception as e: logger.warning(e) @@ -465,10 +471,14 @@ async def check_and_register_extensions(app: FastAPI) -> None: await check_installed_extensions(app) for ext in await get_valid_extensions(False): try: + if is_wasm_extension_id(ext.code): + register_wasm_extension(app, ext.code) + continue register_ext_routes(app, ext) register_ext_tasks(ext) except Exception as exc: logger.error(f"Could not load extension `{ext.code}`: {exc!s}") + await update_installed_extension_state(ext_id=ext.code, active=False) def register_async_tasks() -> None: diff --git a/lnbits/core/extensions/__init__.py b/lnbits/core/extensions/__init__.py index 8e8fd4e92..31bae0077 100644 --- a/lnbits/core/extensions/__init__.py +++ b/lnbits/core/extensions/__init__.py @@ -8,12 +8,21 @@ from .api import ( get_extension_api_method, list_extension_api_methods, ) +from .loader import WasmExtension, load_wasm_extension, register_wasm_extension +from .prototype import InMemoryExtensionAPI, InMemoryExtensionState +from .runtime import ExtensionAPIHost __all__ = [ "ExtensionAPI", + "ExtensionAPIHost", "ExtensionAPIMethod", + "InMemoryExtensionAPI", + "InMemoryExtensionState", + "WasmExtension", "extension_api_contract", "extension_api_method", "get_extension_api_method", + "load_wasm_extension", "list_extension_api_methods", + "register_wasm_extension", ] diff --git a/lnbits/core/extensions/loader.py b/lnbits/core/extensions/loader.py new file mode 100644 index 000000000..d4fce6fb4 --- /dev/null +++ b/lnbits/core/extensions/loader.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from fastapi import FastAPI +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from loguru import logger + +from lnbits.settings import settings + + +@dataclass(frozen=True) +class WasmExtension: + id: str + name: str + version: str + root_path: Path + module_path: Path + wit_path: Path | None + world: str + host_api: str + exports: list[dict[str, Any]] + config: dict[str, Any] + + +def is_wasm_extension_id(ext_id: str) -> bool: + config = load_wasm_extension_config(ext_id) + return bool(config and config.get("extension_type") == "wasm") + + +def is_wasm_extension_dir(ext_dir: Path) -> bool: + config = _load_json(ext_dir / "config.json") + return bool(config and config.get("extension_type") == "wasm") + + +def load_wasm_extension_config(ext_id: str) -> dict[str, Any] | None: + ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id) + return _load_json(ext_dir / "config.json") + + +def register_wasm_extension(app: FastAPI, ext_id: str) -> WasmExtension: + loaded = load_wasm_extension(ext_id) + _mount_wasm_extension_static(app, loaded) + _register_wasm_extension_routes(app, loaded) + + extensions = getattr(app.state, "lnbits_wasm_extensions", {}) + extensions[ext_id] = loaded + app.state.lnbits_wasm_extensions = extensions + + settings.activate_extension_paths(ext_id, "", []) + logger.info( + f"Loaded WASM extension '{loaded.id}' " + f"({loaded.module_path.stat().st_size} bytes)." + ) + return loaded + + +def load_wasm_extension(ext_id: str) -> WasmExtension: + ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id) + config = load_wasm_extension_config(ext_id) + if not config: + raise FileNotFoundError(f"Missing WASM extension config for '{ext_id}'.") + if config.get("extension_type") != "wasm": + raise ValueError(f"Extension '{ext_id}' is not a WASM extension.") + + wasm_config = config.get("wasm") or {} + module_path = _extension_path(ext_dir, wasm_config.get("module")) + wit_path = _optional_extension_path(ext_dir, wasm_config.get("wit")) + _check_wasm_module(module_path) + if wit_path and not wit_path.is_file(): + raise FileNotFoundError(f"WIT file not found: {wit_path}") + + return WasmExtension( + id=config.get("id") or ext_id, + name=config.get("name") or ext_id, + version=config.get("version") or "0.0", + root_path=ext_dir, + module_path=module_path, + wit_path=wit_path, + world=wasm_config.get("world") or "", + host_api=wasm_config.get("host_api") or "lnbits.core.extensions.ExtensionAPI", + exports=wasm_config.get("exports") or [], + config=config, + ) + + +def _mount_wasm_extension_static(app: FastAPI, extension: WasmExtension) -> None: + static_path = extension.root_path / "static" + if not static_path.is_dir(): + return + + mount_path = f"/{extension.id}/static" + if any(getattr(route, "path", None) == mount_path for route in app.routes): + return + + app.mount( + mount_path, + StaticFiles(directory=static_path), + name=f"{extension.id}-static", + ) + + +def _register_wasm_extension_routes(app: FastAPI, extension: WasmExtension) -> None: + for route_config in extension.config.get("routes") or []: + route_path = _wasm_extension_route_path(extension, route_config.get("path")) + entrypoint = _wasm_extension_entrypoint( + extension, route_config.get("entrypoint") + ) + _add_wasm_extension_page_route(app, extension, route_path, entrypoint) + + +def _add_wasm_extension_page_route( + app: FastAPI, + extension: WasmExtension, + route_path: str, + entrypoint: Path, +) -> None: + if any(getattr(route, "path", None) == route_path for route in app.routes): + return + + async def serve_wasm_extension_page() -> FileResponse: + return FileResponse(entrypoint) + + app.add_api_route( + route_path, + serve_wasm_extension_page, + methods=["GET"], + name=f"{extension.id}:{route_path}", + include_in_schema=False, + ) + + +def _load_json(path: Path) -> dict[str, Any] | None: + if not path.is_file(): + return None + with path.open("r", encoding="utf-8") as config_file: + value = json.load(config_file) + if not isinstance(value, dict): + raise ValueError(f"Expected JSON object in '{path}'.") + return value + + +def _extension_path(ext_dir: Path, value: Any) -> Path: + if not isinstance(value, str) or not value: + raise ValueError(f"Missing relative path for extension '{ext_dir.name}'.") + path = (ext_dir / value).resolve() + if ext_dir.resolve() not in path.parents: + raise ValueError(f"Extension path escapes extension root: {value}") + return path + + +def _optional_extension_path(ext_dir: Path, value: Any) -> Path | None: + if value is None: + return None + return _extension_path(ext_dir, value) + + +def _wasm_extension_route_path(extension: WasmExtension, path: Any) -> str: + if not isinstance(path, str) or not path.startswith("/"): + raise ValueError(f"Invalid route path for WASM extension '{extension.id}'.") + if path == "/": + return f"/{extension.id}/" + return f"/{extension.id}{path}" + + +def _wasm_extension_entrypoint(extension: WasmExtension, entrypoint: Any) -> Path: + if not isinstance(entrypoint, str) or not entrypoint.startswith("/"): + raise ValueError(f"Invalid route entrypoint for WASM extension '{extension.id}'.") + + static_prefix = f"/{extension.id}/static/" + if not entrypoint.startswith(static_prefix): + raise ValueError( + f"Route entrypoint for WASM extension '{extension.id}' must be under " + f"'{static_prefix}'." + ) + + path = (extension.root_path / "static" / entrypoint.removeprefix(static_prefix)) + path = path.resolve() + if extension.root_path.resolve() not in path.parents: + raise ValueError(f"Route entrypoint escapes extension root: {entrypoint}") + if not path.is_file(): + raise FileNotFoundError(f"Route entrypoint not found: {path}") + return path + + +def _check_wasm_module(path: Path) -> None: + if not path.is_file(): + raise FileNotFoundError(f"WASM module not found: {path}") + with path.open("rb") as wasm_file: + magic = wasm_file.read(4) + if magic != b"\0asm": + raise ValueError(f"Invalid WASM module: {path}") diff --git a/lnbits/core/extensions/prototype.py b/lnbits/core/extensions/prototype.py new file mode 100644 index 000000000..74b768c95 --- /dev/null +++ b/lnbits/core/extensions/prototype.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import hashlib +import secrets +import time +from dataclasses import dataclass, field + +from .api import ExtensionAPI +from .models import ( + CreateInvoiceRequest, + CreateInvoiceResponse, + KvGetRequest, + KvGetResponse, + KvListRequest, + KvListResponse, + KvSetRequest, + KvSetResponse, + WatchPaymentRequest, + WatchPaymentResponse, +) + + +@dataclass +class InMemoryExtensionState: + storage: dict[str, dict[str, str]] = field(default_factory=dict) + payment_watchers: dict[str, dict[str, str]] = field(default_factory=dict) + + +class InMemoryExtensionAPI(ExtensionAPI): + def __init__( + self, + extension_id: str, + permissions: set[str], + *, + state: InMemoryExtensionState | None = None, + user_id: str | None = None, + wallet_id: str | None = None, + ) -> None: + super().__init__( + extension_id, + permissions, + user_id=user_id, + wallet_id=wallet_id, + ) + self.state = state or InMemoryExtensionState() + + async def storage_get(self, request: KvGetRequest) -> KvGetResponse: + self.require_permission("ext.storage.read_write") + return KvGetResponse(value=self._storage.get(request.key)) + + async def storage_set(self, request: KvSetRequest) -> KvSetResponse: + self.require_permission("ext.storage.read_write") + self._storage[request.key] = request.value + return KvSetResponse() + + async def storage_list(self, request: KvListRequest) -> KvListResponse: + self.require_permission("ext.storage.read_write") + keys = sorted(key for key in self._storage if key.startswith(request.prefix)) + return KvListResponse(keys=keys) + + async def wallet_create_invoice( + self, request: CreateInvoiceRequest + ) -> CreateInvoiceResponse: + self.require_permission("wallet.create_invoice") + if self.wallet_id and request.wallet_id != self.wallet_id: + raise PermissionError("Extension cannot create invoices for this wallet.") + + entropy = secrets.token_urlsafe(16) + invoice_seed = ( + f"{self.extension_id}:{request.wallet_id}:{request.amount_sat}:" + f"{request.memo}:{time.time_ns()}:{entropy}" + ) + payment_hash = hashlib.sha256(invoice_seed.encode()).hexdigest() + return CreateInvoiceResponse( + payment_hash=payment_hash, + payment_request=f"lnbits-prototype-invoice:{payment_hash}", + checking_id=f"{self.extension_id}:{payment_hash}", + ) + + async def payments_watch( + self, request: WatchPaymentRequest + ) -> WatchPaymentResponse: + self.require_permission("payments.watch") + self.state.payment_watchers.setdefault(self.extension_id, {})[ + request.payment_hash + ] = request.callback_export + return WatchPaymentResponse() + + @property + def _storage(self) -> dict[str, str]: + return self.state.storage.setdefault(self.extension_id, {}) diff --git a/lnbits/core/extensions/runtime.py b/lnbits/core/extensions/runtime.py new file mode 100644 index 000000000..593bac21f --- /dev/null +++ b/lnbits/core/extensions/runtime.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import inspect +import re +from collections.abc import Awaitable, Callable, Mapping +from typing import Any + +from pydantic import BaseModel + +from .api import ExtensionAPI, ExtensionAPIMethod, list_extension_api_methods + +HostImport = Callable[..., Awaitable[dict[str, Any]]] + + +class ExtensionAPIHost: + def __init__( + self, + api: ExtensionAPI, + *, + api_cls: type[ExtensionAPI] = ExtensionAPI, + ) -> None: + self.api = api + self.methods = list_extension_api_methods(api_cls) + self._methods_by_host_name = self._index_methods(self.methods) + + async def invoke( + self, + host_name: str, + payload: Mapping[str, Any] | BaseModel | None = None, + ) -> dict[str, Any]: + method = self._require_method(host_name) + request = self._request_model(method, payload) + handler = getattr(self.api, method.python_name) + response = handler(request) + if inspect.isawaitable(response): + response = await response + return self._response_payload(method, response) + + def imports(self) -> dict[str, HostImport]: + return { + _snake_to_camel(method.host_name): self._make_import(method) + for method in self.methods + } + + def import_object(self) -> dict[str, dict[str, HostImport]]: + return {"lnbits:extension/host": self.imports()} + + def _make_import(self, method: ExtensionAPIMethod) -> HostImport: + async def host_import( + payload: Mapping[str, Any] | BaseModel | None = None, + ) -> dict[str, Any]: + return await self.invoke(method.host_name, payload) + + return host_import + + def _require_method(self, host_name: str) -> ExtensionAPIMethod: + method = self._methods_by_host_name.get(host_name) + if not method: + raise KeyError(f"Unknown extension host function '{host_name}'.") + return method + + @staticmethod + def _index_methods( + methods: list[ExtensionAPIMethod], + ) -> dict[str, ExtensionAPIMethod]: + index: dict[str, ExtensionAPIMethod] = {} + for method in methods: + for host_name in { + method.host_name, + _snake_to_camel(method.host_name), + method.host_name.replace("_", "-"), + }: + index[host_name] = method + return index + + @staticmethod + def _request_model( + method: ExtensionAPIMethod, + payload: Mapping[str, Any] | BaseModel | None, + ) -> BaseModel: + if isinstance(payload, method.request_model): + return payload + if isinstance(payload, BaseModel): + payload = payload.dict() + if payload is None: + payload = {} + if not isinstance(payload, Mapping): + raise TypeError( + f"Host function '{method.host_name}' expects an object payload." + ) + data = {_to_snake(key): value for key, value in payload.items()} + if isinstance(data.get("extra"), list): + data["extra"] = dict(data["extra"]) + return method.request_model.parse_obj(data) + + @staticmethod + def _response_payload( + method: ExtensionAPIMethod, + response: Any, + ) -> dict[str, Any]: + if not isinstance(response, method.response_model): + response = method.response_model.parse_obj(response) + return {_snake_to_camel(key): value for key, value in response.dict().items()} + + +def _snake_to_camel(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(part.capitalize() for part in tail) + + +def _to_snake(value: str) -> str: + value = value.replace("-", "_") + return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value).lower() diff --git a/lnbits/core/helpers.py b/lnbits/core/helpers.py index 1b1c24b1b..6b1d408c9 100644 --- a/lnbits/core/helpers.py +++ b/lnbits/core/helpers.py @@ -13,6 +13,7 @@ from lnbits.core.crud import ( update_migration_version, ) from lnbits.core.db import db as core_db +from lnbits.core.extensions.loader import is_wasm_extension_id from lnbits.core.models import DbVersion from lnbits.core.models.extensions import InstallableExtension from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection @@ -22,6 +23,9 @@ from lnbits.settings import settings async def migrate_extension_database( ext: InstallableExtension, current_version: DbVersion | None = None ): + if is_wasm_extension_id(ext.id): + logger.debug(f"Skipping Python migrations for WASM extension '{ext.id}'.") + return try: ext_migrations = importlib.import_module(f"{ext.module_name}.migrations") @@ -105,6 +109,10 @@ async def migrate_databases(): await load_disabled_extension_list() for ext in await get_installed_extensions(): + if is_wasm_extension_id(ext.id): + logger.debug(f"Skipping Python migrations for WASM extension '{ext.id}'.") + continue + current_version = next( (v for v in current_versions if v.db == ext.id), DbVersion(db=ext.id, version=0),