path proxy

This commit is contained in:
Arc
2026-02-21 11:26:38 +00:00
parent ca215063be
commit ec88cf483e
11 changed files with 327 additions and 46 deletions
+18 -1
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import glob import glob
import importlib import importlib
import json
import os import os
import shutil import shutil
import sys import sys
@@ -38,10 +39,10 @@ from lnbits.core.tasks import (
wait_for_paid_invoices, wait_for_paid_invoices,
wait_notification_messages, wait_notification_messages,
) )
from lnbits.core.wasm.extension_host import register_wasm_ext_routes
from lnbits.exceptions import register_exception_handlers from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse from lnbits.helpers import version_parse
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.core.wasm.extension_host import register_wasm_ext_routes
from lnbits.tasks import ( from lnbits.tasks import (
cancel_all_tasks, cancel_all_tasks,
create_permanent_task, create_permanent_task,
@@ -431,6 +432,8 @@ def register_ext_tasks(ext: Extension) -> None:
def register_ext_routes(app: FastAPI, ext: Extension) -> None: def register_ext_routes(app: FastAPI, ext: Extension) -> None:
"""Register FastAPI routes for extension.""" """Register FastAPI routes for extension."""
if ext.extension_type != "wasm":
ext.extension_type = _load_extension_type(ext.code) or ext.extension_type
if ext.extension_type == "wasm": if ext.extension_type == "wasm":
settings.activate_extension_paths(ext.code, ext.upgrade_hash, []) settings.activate_extension_paths(ext.code, ext.upgrade_hash, [])
register_wasm_ext_routes(app, ext) register_wasm_ext_routes(app, ext)
@@ -460,6 +463,20 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
app.include_router(router=ext_route, prefix=prefix) app.include_router(router=ext_route, prefix=prefix)
def _load_extension_type(ext_id: str) -> str | None:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return None
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
return config_json.get("extension_type")
except Exception:
return None
async def check_and_register_extensions(app: FastAPI) -> None: async def check_and_register_extensions(app: FastAPI) -> None:
await check_installed_extensions(app) await check_installed_extensions(app)
for ext in await get_valid_extensions(False): for ext in await get_valid_extensions(False):
+30
View File
@@ -22,6 +22,8 @@ from lnbits.settings import settings
async def migrate_extension_database( async def migrate_extension_database(
ext: InstallableExtension, current_version: DbVersion | None = None ext: InstallableExtension, current_version: DbVersion | None = None
): ):
if _is_wasm_extension(ext):
return
try: try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations") ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
@@ -58,6 +60,34 @@ async def run_migration(
await update_migration_version(conn, db_name, version) await update_migration_version(conn, db_name, version)
def _is_wasm_extension(ext: InstallableExtension) -> bool:
if ext.meta and ext.meta.extension_type == "wasm":
return True
try:
import json
from pathlib import Path
candidate_dirs = [
Path(ext.ext_dir),
Path(settings.lnbits_extensions_path, "extensions", ext.id),
Path(settings.lnbits_path, "extensions", ext.id),
Path.cwd() / "extensions" / ext.id,
]
for base in candidate_dirs:
conf_path = Path(base, "config.json")
if not conf_path.is_file():
continue
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
if config_json.get("extension_type") == "wasm":
return True
except Exception:
pass
return False
def to_valid_user_id(user_id: str) -> UUID: def to_valid_user_id(user_id: str) -> UUID:
if len(user_id) < 32: if len(user_id) < 32:
raise ValueError("User ID must have at least 128 bits") raise ValueError("User ID must have at least 128 bits")
+1 -1
View File
@@ -83,7 +83,7 @@ class ExtensionConfig(BaseModel):
warning: str | None = "" warning: str | None = ""
min_lnbits_version: str | None min_lnbits_version: str | None
max_lnbits_version: str | None max_lnbits_version: str | None
permissions: list["ExtensionPermission"] = [] permissions: list[ExtensionPermission] = []
extension_type: str | None = "python" extension_type: str | None = "python"
public_kv_keys: list[str] = [] public_kv_keys: list[str] = []
+80 -6
View File
@@ -1,6 +1,8 @@
import json
import sys import sys
import traceback import traceback
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path
import httpx import httpx
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
@@ -24,6 +26,7 @@ from lnbits.core.models.extensions import (
ExtensionReview, ExtensionReview,
ExtensionReviewPaymentRequest, ExtensionReviewPaymentRequest,
ExtensionReviewsStatus, ExtensionReviewsStatus,
ExtensionPermission,
ExtensionPermissionsGrant, ExtensionPermissionsGrant,
InstallableExtension, InstallableExtension,
PayToEnableInfo, PayToEnableInfo,
@@ -196,9 +199,12 @@ async def api_enable_extension(
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False) user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
await create_user_extension(user_ext) await create_user_extension(user_ext)
required_permissions = ( permissions_source = (
[p.id for p in ext.meta.permissions] if ext.meta and ext.meta.permissions else [] ext.meta.permissions
if ext.meta and ext.meta.permissions
else _load_permissions_from_config(ext_id)
) )
required_permissions = [p.id for p in permissions_source] if permissions_source else []
granted_permissions = [] granted_permissions = []
if grant and grant.permissions: if grant and grant.permissions:
granted_permissions = grant.permissions granted_permissions = grant.permissions
@@ -212,10 +218,6 @@ async def api_enable_extension(
HTTPStatus.BAD_REQUEST, HTTPStatus.BAD_REQUEST,
"Missing required permissions to enable this extension.", "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: if account_id.is_admin_id or not ext.requires_payment:
user_ext.active = True user_ext.active = True
@@ -267,10 +269,58 @@ async def api_disable_extension(
) )
logger.info(f"Disabling extension: {ext_id}.") logger.info(f"Disabling extension: {ext_id}.")
user_ext.active = False user_ext.active = False
if user_ext.extra and user_ext.extra.granted_permissions:
user_ext.extra.granted_permissions = []
await update_user_extension(user_ext) await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.") return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
@extension_router.put("/{ext_id}/permissions")
async def api_update_extension_permissions(
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(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
)
ext = await get_installed_extension(ext_id)
if not ext:
raise ValueError(f"Extension '{ext_id}' is not installed.")
if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.")
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext:
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
permissions_source = (
ext.meta.permissions
if ext.meta and ext.meta.permissions
else _load_permissions_from_config(ext_id)
)
required_permissions = [p.id for p in permissions_source] if permissions_source else []
granted_permissions = grant.permissions if grant and grant.permissions else []
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 save for 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)
return SimpleStatus(success=True, message=f"Permissions saved for '{ext_id}'.")
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)]) @extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
async def api_activate_extension(ext_id: str) -> SimpleStatus: async def api_activate_extension(ext_id: str) -> SimpleStatus:
try: try:
@@ -543,6 +593,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
installed_exts: list[InstallableExtension] = await get_installed_extensions( installed_exts: list[InstallableExtension] = await get_installed_extensions(
conn=conn conn=conn
) )
user_exts = await get_user_extensions(account_id.id, conn=conn)
all_ext_ids = [ext.code for ext in await get_valid_extensions(conn=conn)] all_ext_ids = [ext.code for ext in await get_valid_extensions(conn=conn)]
inactive_extensions = [ inactive_extensions = [
e.id for e in await get_installed_extensions(active=False, conn=conn) e.id for e in await get_installed_extensions(active=False, conn=conn)
@@ -550,6 +601,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
db_versions = await get_db_versions(conn=conn) db_versions = await get_db_versions(conn=conn)
installed_exts_ids = [e.id for e in installed_exts] installed_exts_ids = [e.id for e in installed_exts]
user_exts_map = {e.extension: e for e in user_exts}
installable_exts = await InstallableExtension.get_installable_extensions( installable_exts = await InstallableExtension.get_installable_extensions(
post_refresh_cache=account_id.is_admin_id post_refresh_cache=account_id.is_admin_id
@@ -578,6 +630,8 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
e.name = installed_ext.name e.name = installed_ext.name
e.short_description = installed_ext.short_description e.short_description = installed_ext.short_description
e.icon = installed_ext.icon e.icon = installed_ext.icon
if e.meta and not e.meta.permissions:
e.meta.permissions = _load_permissions_from_config(e.id)
extension_data = [ extension_data = [
{ {
@@ -598,6 +652,11 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
"permissions": ( "permissions": (
[dict(p) for p in ext.meta.permissions] [dict(p) for p in ext.meta.permissions]
if ext.meta and ext.meta.permissions if ext.meta and ext.meta.permissions
else [dict(p) for p in _load_permissions_from_config(ext.id)]
),
"grantedPermissions": (
user_exts_map.get(ext.id).extra.granted_permissions
if user_exts_map.get(ext.id) and user_exts_map.get(ext.id).extra
else [] else []
), ),
"latestRelease": ( "latestRelease": (
@@ -627,6 +686,21 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
return extension_data return extension_data
def _load_permissions_from_config(ext_id: str) -> list[ExtensionPermission]:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return []
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
permissions = config_json.get("permissions", [])
return [ExtensionPermission(**p) for p in permissions]
except Exception:
return []
@extension_router.get( @extension_router.get(
"/reviews/tags", "/reviews/tags",
dependencies=[Depends(check_account_exists)], dependencies=[Depends(check_account_exists)],
+115 -14
View File
@@ -1,21 +1,24 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import time import time
from pathlib import Path from pathlib import Path
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from lnbits.core.crud.extensions import get_user_extension 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.crud.wallets import get_wallet_for_key, get_wallets_ids
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.core.services import create_invoice, pay_invoice, websocket_updater 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.db import Database
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.tasks import register_invoice_listener, unregister_invoice_listener from lnbits.tasks import register_invoice_listener, unregister_invoice_listener
from .service import WasmExecutionError, wasm_call from .service import WasmExecutionError, wasm_call
@@ -26,7 +29,9 @@ def _renderer(ext_id: str):
def _ext_static_dir(ext_id: str, upgrade_hash: str | None = None) -> Path: def _ext_static_dir(ext_id: str, upgrade_hash: str | None = None) -> Path:
if upgrade_hash: if upgrade_hash:
return Path( return Path(
settings.lnbits_extensions_upgrade_path, f"{ext_id}-{upgrade_hash}", "static" settings.lnbits_extensions_upgrade_path,
f"{ext_id}-{upgrade_hash}",
"static",
) )
return Path(settings.lnbits_extensions_path, "extensions", ext_id, "static") return Path(settings.lnbits_extensions_path, "extensions", ext_id, "static")
@@ -88,10 +93,38 @@ async def _require_wallet_access(user_id: str, wallet_id: str) -> None:
raise HTTPException(403, "Wallet does not belong to user.") raise HTTPException(403, "Wallet does not belong to user.")
async def _wait_for_increment_payment(
ext_id: str, payment_hash: str, db: Database, upgrade_hash: str | None
) -> None:
queue_name = f"wasm:{ext_id}:{payment_hash}:{time.time()}"
invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, queue_name)
try:
while True:
payment = await asyncio.wait_for(invoice_queue.get(), timeout=3600)
if payment.payment_hash != payment_hash:
continue
if payment.pending is False:
try:
new_value = await wasm_call(
ext_id, "increment_counter", [], upgrade_hash=upgrade_hash
)
except WasmExecutionError:
return
await _kv_set(db, ext_id, "counter", str(new_value))
await websocket_updater(f"{ext_id}:counter", str(new_value))
return
except asyncio.TimeoutError:
return
finally:
unregister_invoice_listener(queue_name)
def register_wasm_ext_routes(app, ext) -> None: def register_wasm_ext_routes(app, ext) -> None:
ext_id = ext.code ext_id = ext.code
db = Database(f"ext_{ext_id}") db = Database(f"ext_{ext_id}")
router = APIRouter(prefix=f"/{ext_id}", tags=[f"{ext_id} (wasm)"]) router = APIRouter(prefix=f"/{ext_id}", tags=[f"{ext_id} (wasm)"])
proxy_block = f"/{ext_id}/api/v1/proxy"
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def index(req: Request, user: User = Depends(check_user_exists)): async def index(req: Request, user: User = Depends(check_user_exists)):
@@ -116,9 +149,7 @@ def register_wasm_ext_routes(app, ext) -> None:
@router.get("/api/v1/kv/{key}") @router.get("/api/v1/kv/{key}")
async def api_kv_get(key: str, user: User = Depends(check_user_exists)): async def api_kv_get(key: str, user: User = Depends(check_user_exists)):
await _require_permission(user.id, ext_id, "ext.db.read_write") await _require_permission(user.id, ext_id, "ext.db.read_write")
_check_quota( _check_quota(user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min)
user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min
)
value = await _kv_get(db, ext_id, key) value = await _kv_get(db, ext_id, key)
return {"key": key, "value": value} return {"key": key, "value": value}
@@ -132,11 +163,11 @@ def register_wasm_ext_routes(app, ext) -> None:
return {"key": key, "value": value} return {"key": key, "value": value}
@router.post("/api/v1/kv/{key}") @router.post("/api/v1/kv/{key}")
async def api_kv_set(key: str, payload: dict, user: User = Depends(check_user_exists)): 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") await _require_permission(user.id, ext_id, "ext.db.read_write")
_check_quota( _check_quota(user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min)
user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min
)
value = payload.get("value") value = payload.get("value")
if value is None: if value is None:
raise HTTPException(400, "Missing value") raise HTTPException(400, "Missing value")
@@ -147,9 +178,7 @@ def register_wasm_ext_routes(app, ext) -> None:
@router.post("/api/v1/kv/{key}/increment") @router.post("/api/v1/kv/{key}/increment")
async def api_kv_increment(key: str, user: User = Depends(check_user_exists)): async def api_kv_increment(key: str, user: User = Depends(check_user_exists)):
await _require_permission(user.id, ext_id, "ext.db.read_write") await _require_permission(user.id, ext_id, "ext.db.read_write")
_check_quota( _check_quota(user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min)
user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min
)
if key != "counter": if key != "counter":
raise HTTPException(400, "Only 'counter' is supported in this example.") raise HTTPException(400, "Only 'counter' is supported in this example.")
try: try:
@@ -163,7 +192,9 @@ def register_wasm_ext_routes(app, ext) -> None:
return {"key": key, "value": new_value} return {"key": key, "value": new_value}
@router.post("/api/v1/invoices") @router.post("/api/v1/invoices")
async def api_create_invoice(payload: dict, user: User = Depends(check_user_exists)): async def api_create_invoice(
payload: dict, user: User = Depends(check_user_exists)
):
await _require_permission(user.id, ext_id, "lnbits.invoice.create") await _require_permission(user.id, ext_id, "lnbits.invoice.create")
_check_quota( _check_quota(
user.id, ext_id, "invoice", settings.lnbits_wasm_max_invoice_ops_per_min user.id, ext_id, "invoice", settings.lnbits_wasm_max_invoice_ops_per_min
@@ -187,6 +218,43 @@ def register_wasm_ext_routes(app, ext) -> None:
"amount": payment.amount, "amount": payment.amount,
} }
@router.post("/api/v1/invoices/increment")
async def api_create_increment_invoice(
payload: dict, user: User = Depends(check_user_exists)
):
await _require_permission(user.id, ext_id, "ext.db.read_write")
await _require_permission(user.id, ext_id, "lnbits.invoice.create")
await _require_permission(user.id, ext_id, "lnbits.payments.subscribe")
_check_quota(
user.id, ext_id, "invoice", settings.lnbits_wasm_max_invoice_ops_per_min
)
wallet_id = payload.get("wallet_id")
memo = payload.get("memo") or f"{ext_id} increment"
if not wallet_id:
raise HTTPException(400, "Missing wallet_id")
await _require_wallet_access(user.id, wallet_id)
try:
amount = await wasm_call(
ext_id, "get_increment_amount", [], upgrade_hash=ext.upgrade_hash
)
except WasmExecutionError as exc:
raise HTTPException(500, str(exc)) from exc
if not amount or amount < 0:
raise HTTPException(400, "Invalid amount")
payment = await create_invoice(wallet_id=wallet_id, amount=amount, memo=memo)
asyncio.create_task(
_wait_for_increment_payment(
ext_id, payment.payment_hash, db, ext.upgrade_hash
)
)
return {
"payment_hash": payment.payment_hash,
"payment_request": payment.bolt11,
"amount": payment.amount,
}
@router.post("/api/v1/invoices/pay") @router.post("/api/v1/invoices/pay")
async def api_pay_invoice(payload: dict, user: User = Depends(check_user_exists)): async def api_pay_invoice(payload: dict, user: User = Depends(check_user_exists)):
await _require_permission(user.id, ext_id, "lnbits.invoice.pay") await _require_permission(user.id, ext_id, "lnbits.invoice.pay")
@@ -211,6 +279,37 @@ def register_wasm_ext_routes(app, ext) -> None:
"status": payment.status, "status": payment.status,
} }
@router.post("/api/v1/proxy")
async def api_proxy(payload: dict, req: Request, user: User = Depends(check_user_exists)):
method = str(payload.get("method", "GET")).upper()
path = str(payload.get("path", "")).strip()
if not path.startswith("/") or "://" in path:
raise HTTPException(400, "Invalid path")
if path.startswith(proxy_block):
raise HTTPException(400, "Proxy loop blocked")
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
raise HTTPException(400, "Unsupported method")
await _require_permission(user.id, ext_id, f"api.{method}:{path}")
headers = {}
for key in ("x-api-key", "authorization", "content-type", "accept"):
if key in req.headers:
headers[key] = req.headers[key]
query = payload.get("query") or {}
body = payload.get("body")
async with httpx.AsyncClient(app=app, base_url="http://lnbits") as client:
resp = await client.request(
method, path, params=query, json=body, headers=headers
)
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type"),
)
@router.websocket("/api/v1/events/ws") @router.websocket("/api/v1/events/ws")
async def events_ws(websocket: WebSocket, api_key: str = Query(default="")): async def events_ws(websocket: WebSocket, api_key: str = Query(default="")):
await websocket.accept() await websocket.accept()
@@ -249,6 +348,8 @@ def register_wasm_ext_routes(app, ext) -> None:
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash else "" prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash else ""
app.include_router(router, prefix=prefix) app.include_router(router, prefix=prefix)
_quota_events: dict[tuple[str, str, str], list[float]] = {} _quota_events: dict[tuple[str, str, str], list[float]] = {}
+14 -6
View File
@@ -1,9 +1,8 @@
import asyncio
import json import json
import sys import sys
from pathlib import Path from pathlib import Path
import asyncio
from wasmtime import ( from wasmtime import (
Caller, Caller,
Config, Config,
@@ -108,11 +107,14 @@ def _load_module(module_path: Path, ext_id: str):
config.consume_fuel = True config.consume_fuel = True
engine = Engine(config) engine = Engine(config)
store = Store(engine) store = Store(engine)
store.add_fuel(settings.lnbits_wasm_fuel) if hasattr(store, "add_fuel"):
store.add_fuel(settings.lnbits_wasm_fuel)
module = Module.from_file(engine, str(module_path)) module = Module.from_file(engine, str(module_path))
db = Database(f"ext_{ext_id}") db = Database(f"ext_{ext_id}")
def db_get(caller: Caller, key_ptr: int, key_len: int, out_ptr: int, out_len: int) -> int: 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) return _db_get(db, ext_id, caller, key_ptr, key_len, out_ptr, out_len)
def db_set( def db_set(
@@ -126,7 +128,10 @@ def _load_module(module_path: Path, ext_id: str):
"db_get", "db_get",
Func( Func(
store, store,
FuncType([ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], [ValType.i32()]), FuncType(
[ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()],
[ValType.i32()],
),
db_get, db_get,
), ),
) )
@@ -135,7 +140,10 @@ def _load_module(module_path: Path, ext_id: str):
"db_set", "db_set",
Func( Func(
store, store,
FuncType([ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()], [ValType.i32()]), FuncType(
[ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()],
[ValType.i32()],
),
db_set, db_set,
), ),
) )
+14 -5
View File
@@ -67,14 +67,23 @@ async def wasm_call(
proc.kill() proc.kill()
raise WasmExecutionError("WASM execution timed out") from exc raise WasmExecutionError("WASM execution timed out") from exc
payload = {}
if stdout:
try:
payload = json.loads(stdout.decode())
except json.JSONDecodeError:
payload = {}
if proc.returncode != 0: if proc.returncode != 0:
detail = stderr.decode().strip() if stderr else "WASM runner error" detail = payload.get("error")
if not detail and stderr:
detail = stderr.decode().strip()
if not detail:
detail = "WASM runner error"
raise WasmExecutionError(detail) raise WasmExecutionError(detail)
try: if not payload:
payload = json.loads(stdout.decode()) if stdout else {} raise WasmExecutionError("Invalid WASM runner output")
except json.JSONDecodeError as exc:
raise WasmExecutionError("Invalid WASM runner output") from exc
if not payload.get("ok"): if not payload.get("ok"):
raise WasmExecutionError(payload.get("error", "WASM execution failed")) raise WasmExecutionError(payload.get("error", "WASM execution failed"))
+1 -1
View File
@@ -75,7 +75,7 @@ class ExtensionsSettings(LNbitsSettings):
lnbits_extensions_builder_manifest_url: str = Field( lnbits_extensions_builder_manifest_url: str = Field(
default="https://raw.githubusercontent.com/lnbits/extension_builder_stub/refs/heads/main/manifest.json" 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_timeout_seconds: float = Field(default=3.0, ge=0.1)
lnbits_wasm_fuel: int = Field(default=50_000, ge=1_000) 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_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_db_ops_per_min: int = Field(default=120, ge=0)
File diff suppressed because one or more lines are too long
+38 -7
View File
@@ -255,8 +255,13 @@ window.PageExtensions = {
}, },
async enableExtensionForUser(extension) { async enableExtensionForUser(extension) {
if (extension.permissions && extension.permissions.length) { if (extension.permissions && extension.permissions.length) {
this.openPermissionsDialog(extension) if (!extension._grantedPermissions) {
return Quasar.Notify.create({
type: 'warning',
message: 'Save permissions before enabling this extension.'
})
return
}
} }
if (extension.isPaymentRequired) { if (extension.isPaymentRequired) {
this.showPayToEnable(extension) this.showPayToEnable(extension)
@@ -329,7 +334,19 @@ window.PageExtensions = {
this.permissionsDialog.extension = null this.permissionsDialog.extension = null
this.permissionsDialog.checked = [] this.permissionsDialog.checked = []
}, },
confirmPermissionsDialog() { openPermissionsForExtension(extension) {
if (!extension.permissions || !extension.permissions.length) {
Quasar.Notify.create({
type: 'warning',
message: 'This extension does not declare permissions.'
})
return
}
this.permissionsDialog.extension = extension
this.permissionsDialog.checked = []
this.permissionsDialog.show = true
},
async confirmPermissionsDialog() {
const ext = this.permissionsDialog.extension const ext = this.permissionsDialog.extension
const granted = this.permissionsDialog.checked.slice() const granted = this.permissionsDialog.checked.slice()
this.permissionsDialog.show = false this.permissionsDialog.show = false
@@ -337,10 +354,19 @@ window.PageExtensions = {
this.permissionsDialog.checked = [] this.permissionsDialog.checked = []
if (!ext) return if (!ext) return
ext._grantedPermissions = granted ext._grantedPermissions = granted
if (ext.isPaymentRequired) { try {
this.showPayToEnable(ext, granted) await LNbits.api.request(
} else { 'PUT',
this.enableExtension(ext, granted) `/api/v1/extension/${ext.id}/permissions`,
this.g.user.wallets[0].adminkey,
{permissions: granted}
)
Quasar.Notify.create({
type: 'positive',
message: 'Permissions saved.'
})
} catch (err) {
LNbits.utils.notifyApiError(err)
} }
}, },
updatePayToInstallData(extension) { updatePayToInstallData(extension) {
@@ -881,6 +907,11 @@ window.PageExtensions = {
async fetchAllExtensions() { async fetchAllExtensions() {
try { try {
const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`) const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`)
data.forEach(ext => {
if (ext.grantedPermissions && ext.grantedPermissions.length) {
ext._grantedPermissions = ext.grantedPermissions
}
})
return data return data
} catch (error) { } catch (error) {
console.warn(error) console.warn(error)
+15 -4
View File
@@ -329,6 +329,19 @@
<span v-text="$t('enable_extension_details')"> <span v-text="$t('enable_extension_details')">
</span> </q-tooltip </span> </q-tooltip
></q-btn> ></q-btn>
<q-btn
v-if="
extension.isInstalled &&
extension.isActive &&
!g.user.extensions.includes(extension.id) &&
extension.permissions &&
extension.permissions.length
"
flat
color="grey-5"
@click="openPermissionsForExtension(extension)"
label="Permissions"
></q-btn>
<q-btn <q-btn
@click="showManageExtension(extension)" @click="showManageExtension(extension)"
@@ -925,9 +938,7 @@
<q-card class="q-pa-md" style="min-width: 360px; max-width: 90vw"> <q-card class="q-pa-md" style="min-width: 360px; max-width: 90vw">
<q-card-section> <q-card-section>
<div class="text-h6">Permissions required</div> <div class="text-h6">Permissions required</div>
<div class="text-caption text-grey"> <div class="text-caption text-grey">This extension can:</div>
This extension can:
</div>
<q-list v-if="permissionsDialog.extension"> <q-list v-if="permissionsDialog.extension">
<q-item <q-item
v-for="perm in permissionsDialog.extension.permissions" v-for="perm in permissionsDialog.extension.permissions"
@@ -954,7 +965,7 @@
<q-btn <q-btn
color="primary" color="primary"
:disable="!permissionsAllChecked" :disable="!permissionsAllChecked"
v-text="$t('enable')" label="Save"
@click="confirmPermissionsDialog" @click="confirmPermissionsDialog"
></q-btn> ></q-btn>
</q-card-actions> </q-card-actions>