This commit is contained in:
Arc
2026-02-21 09:18:13 +00:00
parent 2069e3de8b
commit 26f0b69085
13 changed files with 757 additions and 6 deletions
+7
View File
@@ -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")
+33 -1
View File
@@ -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", []),
),
)
+6
View File
@@ -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)
+31 -2
View File
@@ -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
View File
+265
View File
@@ -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
+179
View File
@@ -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())
+82
View File
@@ -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"])
+5
View File
@@ -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:
+52 -3
View File
@@ -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(
+5
View File
@@ -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>
+40
View File
@@ -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"