basic extension loading

This commit is contained in:
Vlad Stan
2026-07-08 11:54:56 +03:00
parent 937b9cb5f1
commit 42afbc0576
6 changed files with 429 additions and 2 deletions
+12 -2
View File
@@ -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:
+9
View File
@@ -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",
]
+196
View File
@@ -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}")
+91
View File
@@ -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, {})
+113
View File
@@ -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()
+8
View File
@@ -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),