init
This commit is contained in:
@@ -41,6 +41,7 @@ from lnbits.core.tasks import (
|
||||
from lnbits.exceptions import register_exception_handlers
|
||||
from lnbits.helpers import version_parse
|
||||
from lnbits.settings import settings
|
||||
from lnbits.core.wasm.extension_host import register_wasm_ext_routes
|
||||
from lnbits.tasks import (
|
||||
cancel_all_tasks,
|
||||
create_permanent_task,
|
||||
@@ -419,6 +420,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 +431,10 @@ 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":
|
||||
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")
|
||||
|
||||
@@ -83,6 +83,9 @@ 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] = []
|
||||
|
||||
def is_version_compatible(self) -> bool:
|
||||
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
|
||||
@@ -115,6 +118,18 @@ 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
|
||||
|
||||
|
||||
class ExtensionPermission(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
description: str
|
||||
dangerous: bool | None = False
|
||||
|
||||
|
||||
class ExtensionPermissionsGrant(BaseModel):
|
||||
permissions: list[str] = []
|
||||
|
||||
|
||||
class UserExtension(BaseModel):
|
||||
@@ -147,6 +162,8 @@ 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] = []
|
||||
|
||||
@property
|
||||
def module_name(self) -> str:
|
||||
@@ -170,6 +187,8 @@ 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 [],
|
||||
)
|
||||
|
||||
|
||||
@@ -331,6 +350,9 @@ 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] = []
|
||||
archive: str | None = None
|
||||
featured: bool = False
|
||||
paid_features: str | None = None
|
||||
@@ -454,6 +476,10 @@ 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", [])
|
||||
|
||||
if (
|
||||
self.meta
|
||||
@@ -572,6 +598,9 @@ 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,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -617,7 +646,10 @@ 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", []),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -91,6 +91,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 +126,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)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from http import HTTPStatus
|
||||
|
||||
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
|
||||
|
||||
@@ -24,6 +24,7 @@ from lnbits.core.models.extensions import (
|
||||
ExtensionReview,
|
||||
ExtensionReviewPaymentRequest,
|
||||
ExtensionReviewsStatus,
|
||||
ExtensionPermissionsGrant,
|
||||
InstallableExtension,
|
||||
PayToEnableInfo,
|
||||
ReleasePaymentInfo,
|
||||
@@ -174,7 +175,9 @@ 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,
|
||||
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(
|
||||
@@ -193,6 +196,27 @@ async def api_enable_extension(
|
||||
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
|
||||
await create_user_extension(user_ext)
|
||||
|
||||
required_permissions = (
|
||||
[p.id for p in ext.meta.permissions] if ext.meta and ext.meta.permissions else []
|
||||
)
|
||||
granted_permissions = []
|
||||
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
|
||||
|
||||
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.",
|
||||
)
|
||||
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)
|
||||
|
||||
if account_id.is_admin_id or not ext.requires_payment:
|
||||
user_ext.active = True
|
||||
await update_user_extension(user_ext)
|
||||
@@ -571,6 +595,11 @@ 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 []
|
||||
),
|
||||
"latestRelease": (
|
||||
dict(ext.meta.latest_release)
|
||||
if ext.meta and ext.meta.latest_release
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_extension
|
||||
from lnbits.core.crud.wallets import get_wallet_for_key, get_wallets_ids
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.services import create_invoice, pay_invoice, websocket_updater
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import register_invoice_listener, unregister_invoice_listener
|
||||
from .service import WasmExecutionError, wasm_call
|
||||
|
||||
|
||||
def _renderer(ext_id: str):
|
||||
return template_renderer([f"{ext_id}/templates"])
|
||||
|
||||
|
||||
def _ext_static_dir(ext_id: str, upgrade_hash: str | None = None) -> Path:
|
||||
if upgrade_hash:
|
||||
return Path(
|
||||
settings.lnbits_extensions_upgrade_path, f"{ext_id}-{upgrade_hash}", "static"
|
||||
)
|
||||
return Path(settings.lnbits_extensions_path, "extensions", ext_id, "static")
|
||||
|
||||
|
||||
def _ensure_kv_table(db: Database, ext_id: str) -> str:
|
||||
table = f"{ext_id}.kv" if db.schema else "kv"
|
||||
query = f"""
|
||||
CREATE TABLE IF NOT EXISTS {table} (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
"""
|
||||
return query
|
||||
|
||||
|
||||
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"
|
||||
row = await db.fetchone(
|
||||
f"SELECT value FROM {table} WHERE key = :key",
|
||||
{"key": key},
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
return row.get("value")
|
||||
|
||||
|
||||
async def _kv_set(db: Database, ext_id: str, key: str, value: str) -> None:
|
||||
await db.execute(_ensure_kv_table(db, ext_id))
|
||||
table = f"{ext_id}.kv" if db.schema else "kv"
|
||||
existing = await db.fetchone(
|
||||
f"SELECT key FROM {table} WHERE key = :key",
|
||||
{"key": key},
|
||||
)
|
||||
if existing:
|
||||
await db.execute(
|
||||
f"UPDATE {table} SET value = :value WHERE key = :key",
|
||||
{"key": key, "value": value},
|
||||
)
|
||||
else:
|
||||
await db.execute(
|
||||
f"INSERT INTO {table} (key, value) VALUES (:key, :value)",
|
||||
{"key": key, "value": value},
|
||||
)
|
||||
|
||||
|
||||
async def _require_permission(user_id: str, ext_id: str, permission: str) -> None:
|
||||
user_ext = await get_user_extension(user_id, ext_id)
|
||||
if not user_ext or not user_ext.active:
|
||||
raise HTTPException(403, "Extension not enabled.")
|
||||
granted = user_ext.extra.granted_permissions if user_ext.extra else []
|
||||
if permission not in (granted or []):
|
||||
raise HTTPException(403, f"Missing permission: {permission}")
|
||||
|
||||
|
||||
async def _require_wallet_access(user_id: str, wallet_id: str) -> None:
|
||||
wallet_ids = await get_wallets_ids(user_id, deleted=False)
|
||||
if wallet_id not in wallet_ids:
|
||||
raise HTTPException(403, "Wallet does not belong to user.")
|
||||
|
||||
|
||||
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)"])
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def index(req: Request, user: User = Depends(check_user_exists)):
|
||||
try:
|
||||
return _renderer(ext_id).TemplateResponse(
|
||||
f"{ext_id}/index.html",
|
||||
{"request": req, "user": user.json()},
|
||||
)
|
||||
except Exception:
|
||||
return HTMLResponse("Extension page not found", status_code=404)
|
||||
|
||||
@router.get("/public/{key}", response_class=HTMLResponse)
|
||||
async def public_page(req: Request, key: str):
|
||||
try:
|
||||
return _renderer(ext_id).TemplateResponse(
|
||||
f"{ext_id}/public_page.html",
|
||||
{"request": req, "key": key, "public": True},
|
||||
)
|
||||
except Exception:
|
||||
return HTMLResponse("Public page not found", status_code=404)
|
||||
|
||||
@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")
|
||||
_check_quota(
|
||||
user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min
|
||||
)
|
||||
value = await _kv_get(db, ext_id, key)
|
||||
return {"key": key, "value": value}
|
||||
|
||||
@router.get("/api/v1/public/kv/{key}")
|
||||
async def api_kv_get_public(key: str):
|
||||
public_keys = ext.public_kv_keys or []
|
||||
if key not in public_keys:
|
||||
raise HTTPException(404, "Key not public")
|
||||
_check_quota("public", ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min)
|
||||
value = await _kv_get(db, ext_id, key)
|
||||
return {"key": key, "value": value}
|
||||
|
||||
@router.post("/api/v1/kv/{key}")
|
||||
async def api_kv_set(key: str, payload: dict, user: User = Depends(check_user_exists)):
|
||||
await _require_permission(user.id, ext_id, "ext.db.read_write")
|
||||
_check_quota(
|
||||
user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min
|
||||
)
|
||||
value = payload.get("value")
|
||||
if value is None:
|
||||
raise HTTPException(400, "Missing value")
|
||||
await _kv_set(db, ext_id, key, str(value))
|
||||
await websocket_updater(f"{ext_id}:{key}", str(value))
|
||||
return {"key": key, "value": value}
|
||||
|
||||
@router.post("/api/v1/kv/{key}/increment")
|
||||
async def api_kv_increment(key: str, user: User = Depends(check_user_exists)):
|
||||
await _require_permission(user.id, ext_id, "ext.db.read_write")
|
||||
_check_quota(
|
||||
user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min
|
||||
)
|
||||
if key != "counter":
|
||||
raise HTTPException(400, "Only 'counter' is supported in this example.")
|
||||
try:
|
||||
new_value = await wasm_call(
|
||||
ext_id, "increment_counter", [], upgrade_hash=ext.upgrade_hash
|
||||
)
|
||||
except WasmExecutionError as exc:
|
||||
raise HTTPException(500, str(exc)) from exc
|
||||
await _kv_set(db, ext_id, key, str(new_value))
|
||||
await websocket_updater(f"{ext_id}:{key}", str(new_value))
|
||||
return {"key": key, "value": new_value}
|
||||
|
||||
@router.post("/api/v1/invoices")
|
||||
async def api_create_invoice(payload: dict, user: User = Depends(check_user_exists)):
|
||||
await _require_permission(user.id, ext_id, "lnbits.invoice.create")
|
||||
_check_quota(
|
||||
user.id, ext_id, "invoice", settings.lnbits_wasm_max_invoice_ops_per_min
|
||||
)
|
||||
wallet_id = payload.get("wallet_id")
|
||||
amount = payload.get("amount")
|
||||
memo = payload.get("memo") or f"{ext_id} invoice"
|
||||
if not wallet_id or not amount:
|
||||
raise HTTPException(400, "Missing wallet_id or amount")
|
||||
await _require_wallet_access(user.id, wallet_id)
|
||||
|
||||
try:
|
||||
amount = int(amount)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "Invalid amount")
|
||||
|
||||
payment = await create_invoice(wallet_id=wallet_id, amount=amount, memo=memo)
|
||||
return {
|
||||
"payment_hash": payment.payment_hash,
|
||||
"payment_request": payment.bolt11,
|
||||
"amount": payment.amount,
|
||||
}
|
||||
|
||||
@router.post("/api/v1/invoices/pay")
|
||||
async def api_pay_invoice(payload: dict, user: User = Depends(check_user_exists)):
|
||||
await _require_permission(user.id, ext_id, "lnbits.invoice.pay")
|
||||
_check_quota(
|
||||
user.id, ext_id, "invoice", settings.lnbits_wasm_max_invoice_ops_per_min
|
||||
)
|
||||
wallet_id = payload.get("wallet_id")
|
||||
payment_request = payload.get("payment_request")
|
||||
if not wallet_id or not payment_request:
|
||||
raise HTTPException(400, "Missing wallet_id or payment_request")
|
||||
await _require_wallet_access(user.id, wallet_id)
|
||||
|
||||
payment = await pay_invoice(
|
||||
wallet_id=wallet_id,
|
||||
payment_request=payment_request,
|
||||
description=f"{ext_id} payment",
|
||||
tag=ext_id,
|
||||
)
|
||||
return {
|
||||
"payment_hash": payment.payment_hash,
|
||||
"amount_msat": payment.amount_msat,
|
||||
"status": payment.status,
|
||||
}
|
||||
|
||||
@router.websocket("/api/v1/events/ws")
|
||||
async def events_ws(websocket: WebSocket, api_key: str = Query(default="")):
|
||||
await websocket.accept()
|
||||
wallet = await get_wallet_for_key(api_key) if api_key else None
|
||||
if not wallet:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
try:
|
||||
await _require_permission(wallet.user, ext_id, "lnbits.payments.subscribe")
|
||||
except HTTPException:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
wallet_ids = await get_wallets_ids(wallet.user, deleted=False)
|
||||
queue_name = f"wasm:{ext_id}:{wallet.user}:{id(websocket)}"
|
||||
invoice_queue: asyncio.Queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, queue_name)
|
||||
|
||||
try:
|
||||
while True:
|
||||
payment = await invoice_queue.get()
|
||||
if payment.wallet_id in wallet_ids:
|
||||
await websocket.send_json(payment.dict())
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
unregister_invoice_listener(queue_name)
|
||||
|
||||
static_dir = _ext_static_dir(ext_id, ext.upgrade_hash)
|
||||
if static_dir.is_dir():
|
||||
app.mount(
|
||||
f"/{ext_id}/static",
|
||||
StaticFiles(directory=static_dir),
|
||||
name=f"{ext_id}_static",
|
||||
)
|
||||
|
||||
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash else ""
|
||||
app.include_router(router, prefix=prefix)
|
||||
_quota_events: dict[tuple[str, str, str], list[float]] = {}
|
||||
|
||||
|
||||
def _check_quota(user_id: str, ext_id: str, action: str, limit: int) -> None:
|
||||
if limit <= 0:
|
||||
return
|
||||
now = time.time()
|
||||
key = (user_id, ext_id, action)
|
||||
events = _quota_events.get(key, [])
|
||||
events = [t for t in events if now - t < 60]
|
||||
if len(events) >= limit:
|
||||
raise HTTPException(429, "WASM quota exceeded")
|
||||
events.append(now)
|
||||
_quota_events[key] = events
|
||||
@@ -0,0 +1,179 @@
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import asyncio
|
||||
|
||||
from wasmtime import (
|
||||
Caller,
|
||||
Config,
|
||||
Engine,
|
||||
Func,
|
||||
FuncType,
|
||||
Linker,
|
||||
Module,
|
||||
Store,
|
||||
ValType,
|
||||
)
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _ensure_kv_table(db: Database, ext_id: str) -> str:
|
||||
table = f"{ext_id}.kv" if db.schema else "kv"
|
||||
return f"""
|
||||
CREATE TABLE IF NOT EXISTS {table} (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _get_memory(caller: Caller):
|
||||
memory = caller.get_export("memory")
|
||||
if memory is None:
|
||||
raise RuntimeError("WASM module does not export memory")
|
||||
return memory
|
||||
|
||||
|
||||
def _read_bytes(caller: Caller, ptr: int, length: int) -> bytes:
|
||||
memory = _get_memory(caller)
|
||||
return memory.read(caller, ptr, ptr + length)
|
||||
|
||||
|
||||
def _write_bytes(caller: Caller, ptr: int, data: bytes) -> None:
|
||||
memory = _get_memory(caller)
|
||||
memory.write(caller, data, ptr)
|
||||
|
||||
|
||||
def _db_get(
|
||||
db: Database,
|
||||
ext_id: str,
|
||||
caller: Caller,
|
||||
key_ptr: int,
|
||||
key_len: int,
|
||||
out_ptr: int,
|
||||
out_len: int,
|
||||
) -> int:
|
||||
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}))
|
||||
if not row:
|
||||
return 0
|
||||
value = str(row.get("value", ""))
|
||||
data = value.encode()[: max(0, out_len)]
|
||||
_write_bytes(caller, out_ptr, data)
|
||||
return len(data)
|
||||
|
||||
|
||||
def _db_set(
|
||||
db: Database,
|
||||
ext_id: str,
|
||||
caller: Caller,
|
||||
key_ptr: int,
|
||||
key_len: int,
|
||||
val_ptr: int,
|
||||
val_len: int,
|
||||
) -> int:
|
||||
key = _read_bytes(caller, key_ptr, key_len).decode(errors="ignore")
|
||||
value = _read_bytes(caller, val_ptr, val_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 key FROM {table} WHERE key = :key", {"key": key}))
|
||||
if row:
|
||||
_run(
|
||||
db.execute(
|
||||
f"UPDATE {table} SET value = :value WHERE key = :key",
|
||||
{"key": key, "value": value},
|
||||
)
|
||||
)
|
||||
else:
|
||||
_run(
|
||||
db.execute(
|
||||
f"INSERT INTO {table} (key, value) VALUES (:key, :value)",
|
||||
{"key": key, "value": value},
|
||||
)
|
||||
)
|
||||
return len(value)
|
||||
|
||||
|
||||
def _load_module(module_path: Path, ext_id: str):
|
||||
config = Config()
|
||||
config.consume_fuel = True
|
||||
engine = Engine(config)
|
||||
store = Store(engine)
|
||||
store.add_fuel(settings.lnbits_wasm_fuel)
|
||||
module = Module.from_file(engine, str(module_path))
|
||||
db = Database(f"ext_{ext_id}")
|
||||
|
||||
def db_get(caller: Caller, key_ptr: int, key_len: int, out_ptr: int, out_len: int) -> int:
|
||||
return _db_get(db, ext_id, caller, key_ptr, key_len, out_ptr, out_len)
|
||||
|
||||
def db_set(
|
||||
caller: Caller, key_ptr: int, key_len: int, val_ptr: int, val_len: int
|
||||
) -> int:
|
||||
return _db_set(db, ext_id, caller, key_ptr, key_len, val_ptr, val_len)
|
||||
|
||||
linker = Linker(engine)
|
||||
linker.define(
|
||||
"host",
|
||||
"db_get",
|
||||
Func(
|
||||
store,
|
||||
FuncType([ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], [ValType.i32()]),
|
||||
db_get,
|
||||
),
|
||||
)
|
||||
linker.define(
|
||||
"host",
|
||||
"db_set",
|
||||
Func(
|
||||
store,
|
||||
FuncType([ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], [ValType.i32()]),
|
||||
db_set,
|
||||
),
|
||||
)
|
||||
|
||||
instance = linker.instantiate(store, module)
|
||||
return store, instance
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 4:
|
||||
sys.stderr.write(
|
||||
"usage: runner.py <module_path> <ext_id> <function> [args...]\n"
|
||||
)
|
||||
return 2
|
||||
|
||||
module_path = Path(sys.argv[1])
|
||||
ext_id = sys.argv[2]
|
||||
function_name = sys.argv[3]
|
||||
args = sys.argv[4:]
|
||||
|
||||
if not module_path.exists():
|
||||
sys.stderr.write(f"module not found: {module_path}\n")
|
||||
return 2
|
||||
|
||||
try:
|
||||
store, instance = _load_module(module_path, ext_id)
|
||||
func = instance.exports(store)[function_name]
|
||||
int_args = [int(a) for a in args]
|
||||
result = func(store, *int_args)
|
||||
except Exception as exc:
|
||||
payload = {"ok": False, "error": str(exc)}
|
||||
sys.stdout.write(json.dumps(payload))
|
||||
return 1
|
||||
|
||||
payload = {"ok": True, "result": int(result)}
|
||||
sys.stdout.write(json.dumps(payload))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,82 @@
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
WASM_RUNNER = Path(__file__).with_name("runner.py")
|
||||
|
||||
|
||||
class WasmExecutionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def resolve_module_path(ext_id: str, upgrade_hash: str | None = None) -> Path:
|
||||
if upgrade_hash:
|
||||
ext_dir = Path(
|
||||
settings.lnbits_extensions_upgrade_path, f"{ext_id}-{upgrade_hash}"
|
||||
)
|
||||
else:
|
||||
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
|
||||
wasm_dir = ext_dir / "wasm"
|
||||
wasm_path = wasm_dir / "module.wasm"
|
||||
if wasm_path.exists():
|
||||
if (
|
||||
settings.lnbits_wasm_max_module_bytes > 0
|
||||
and wasm_path.stat().st_size > settings.lnbits_wasm_max_module_bytes
|
||||
):
|
||||
raise WasmExecutionError("WASM module exceeds size limit.")
|
||||
return wasm_path
|
||||
wat_path = wasm_dir / "module.wat"
|
||||
if wat_path.exists():
|
||||
if (
|
||||
settings.lnbits_wasm_max_module_bytes > 0
|
||||
and wat_path.stat().st_size > settings.lnbits_wasm_max_module_bytes
|
||||
):
|
||||
raise WasmExecutionError("WASM module exceeds size limit.")
|
||||
return wat_path
|
||||
raise WasmExecutionError(f"No wasm module found for extension '{ext_id}'.")
|
||||
|
||||
|
||||
async def wasm_call(
|
||||
ext_id: str,
|
||||
function: str,
|
||||
args: list[int],
|
||||
timeout_s: float | None = None,
|
||||
upgrade_hash: str | None = None,
|
||||
) -> int:
|
||||
module_path = resolve_module_path(ext_id, upgrade_hash)
|
||||
if timeout_s is None:
|
||||
timeout_s = settings.lnbits_wasm_timeout_seconds
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
str(WASM_RUNNER),
|
||||
str(module_path),
|
||||
ext_id,
|
||||
function,
|
||||
*[str(a) for a in args],
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
|
||||
except asyncio.TimeoutError as exc:
|
||||
proc.kill()
|
||||
raise WasmExecutionError("WASM execution timed out") from exc
|
||||
|
||||
if proc.returncode != 0:
|
||||
detail = stderr.decode().strip() if stderr else "WASM runner error"
|
||||
raise WasmExecutionError(detail)
|
||||
|
||||
try:
|
||||
payload = json.loads(stdout.decode()) if stdout else {}
|
||||
except json.JSONDecodeError as exc:
|
||||
raise WasmExecutionError("Invalid WASM runner output") from exc
|
||||
|
||||
if not payload.get("ok"):
|
||||
raise WasmExecutionError(payload.get("error", "WASM execution failed"))
|
||||
|
||||
return int(payload["result"])
|
||||
@@ -75,6 +75,11 @@ class ExtensionsSettings(LNbitsSettings):
|
||||
lnbits_extensions_builder_manifest_url: str = Field(
|
||||
default="https://raw.githubusercontent.com/lnbits/extension_builder_stub/refs/heads/main/manifest.json"
|
||||
)
|
||||
lnbits_wasm_timeout_seconds: float = Field(default=1.0, ge=0.1)
|
||||
lnbits_wasm_fuel: int = Field(default=50_000, ge=1_000)
|
||||
lnbits_wasm_max_module_bytes: int = Field(default=1_000_000, ge=0)
|
||||
lnbits_wasm_max_db_ops_per_min: int = Field(default=120, ge=0)
|
||||
lnbits_wasm_max_invoice_ops_per_min: int = Field(default=30, ge=0)
|
||||
|
||||
@property
|
||||
def extension_builder_working_dir_path(self) -> Path:
|
||||
|
||||
@@ -28,6 +28,11 @@ window.PageExtensions = {
|
||||
paylinkWebsocket: null,
|
||||
searchToggle: false,
|
||||
reviewsUrl: null,
|
||||
permissionsDialog: {
|
||||
show: false,
|
||||
extension: null,
|
||||
checked: []
|
||||
},
|
||||
reviewsDialog: {
|
||||
show: false,
|
||||
extension: null,
|
||||
@@ -92,6 +97,14 @@ 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))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
filterExtensions(term, tab) {
|
||||
// Filter the extensions list
|
||||
@@ -241,18 +254,27 @@ window.PageExtensions = {
|
||||
})
|
||||
},
|
||||
async enableExtensionForUser(extension) {
|
||||
if (extension.permissions && extension.permissions.length) {
|
||||
this.openPermissionsDialog(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) : [])
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/${extension.id}/enable`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
this.g.user.wallets[0].adminkey,
|
||||
granted.length ? {permissions: granted} : null
|
||||
)
|
||||
.then(response => {
|
||||
this.g.user.extensions = this.g.user.extensions.concat([extension.id])
|
||||
@@ -287,13 +309,40 @@ 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 = []
|
||||
this.permissionsDialog.show = true
|
||||
},
|
||||
cancelPermissionsDialog() {
|
||||
this.permissionsDialog.show = false
|
||||
this.permissionsDialog.extension = null
|
||||
this.permissionsDialog.checked = []
|
||||
},
|
||||
confirmPermissionsDialog() {
|
||||
const ext = this.permissionsDialog.extension
|
||||
const granted = this.permissionsDialog.checked.slice()
|
||||
this.permissionsDialog.show = false
|
||||
this.permissionsDialog.extension = null
|
||||
this.permissionsDialog.checked = []
|
||||
if (!ext) return
|
||||
ext._grantedPermissions = granted
|
||||
if (ext.isPaymentRequired) {
|
||||
this.showPayToEnable(ext, granted)
|
||||
} else {
|
||||
this.enableExtension(ext, granted)
|
||||
}
|
||||
},
|
||||
updatePayToInstallData(extension) {
|
||||
LNbits.api
|
||||
.request(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -150,6 +150,58 @@
|
||||
autocomplete="off"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<p>
|
||||
<span>WASM Sandbox Limits</span>
|
||||
</p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_wasm_timeout_seconds"
|
||||
label="WASM timeout (seconds)"
|
||||
hint="Max wall time per WASM call"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_wasm_fuel"
|
||||
label="WASM fuel limit"
|
||||
hint="Max instruction budget per call"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_wasm_max_module_bytes"
|
||||
label="Max module size (bytes)"
|
||||
hint="Reject larger WASM/WAT modules"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_wasm_max_db_ops_per_min"
|
||||
label="Max DB ops per minute"
|
||||
hint="Per user per extension"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_wasm_max_invoice_ops_per_min"
|
||||
label="Max invoice ops per minute"
|
||||
hint="Per user per extension"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
@@ -921,6 +921,46 @@
|
||||
</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>
|
||||
</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"
|
||||
v-text="$t('enable')"
|
||||
@click="confirmPermissionsDialog"
|
||||
></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showExtensionDetailsDialog" position="top">
|
||||
<q-card
|
||||
v-if="selectedExtensionDetails"
|
||||
|
||||
Reference in New Issue
Block a user