init
This commit is contained in:
@@ -41,6 +41,7 @@ from lnbits.core.tasks import (
|
|||||||
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,
|
||||||
@@ -419,6 +420,8 @@ def register_new_ratelimiter(app: FastAPI) -> Callable:
|
|||||||
|
|
||||||
def register_ext_tasks(ext: Extension) -> None:
|
def register_ext_tasks(ext: Extension) -> None:
|
||||||
"""Register extension async tasks."""
|
"""Register extension async tasks."""
|
||||||
|
if ext.extension_type == "wasm":
|
||||||
|
return
|
||||||
ext_module = importlib.import_module(ext.module_name)
|
ext_module = importlib.import_module(ext.module_name)
|
||||||
|
|
||||||
if hasattr(ext_module, f"{ext.code}_start"):
|
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:
|
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
|
||||||
"""Register FastAPI routes for extension."""
|
"""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_module = importlib.import_module(ext.module_name)
|
||||||
|
|
||||||
ext_route = getattr(ext_module, f"{ext.code}_ext")
|
ext_route = getattr(ext_module, f"{ext.code}_ext")
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ 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"] = []
|
||||||
|
extension_type: str | None = "python"
|
||||||
|
public_kv_keys: list[str] = []
|
||||||
|
|
||||||
def is_version_compatible(self) -> bool:
|
def is_version_compatible(self) -> bool:
|
||||||
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
|
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
|
||||||
@@ -115,6 +118,18 @@ class PayToEnableInfo(BaseModel):
|
|||||||
class UserExtensionInfo(BaseModel):
|
class UserExtensionInfo(BaseModel):
|
||||||
paid_to_enable: bool | None = False
|
paid_to_enable: bool | None = False
|
||||||
payment_hash_to_enable: str | None = None
|
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):
|
class UserExtension(BaseModel):
|
||||||
@@ -147,6 +162,8 @@ class Extension(BaseModel):
|
|||||||
short_description: str | None = None
|
short_description: str | None = None
|
||||||
tile: str | None = None
|
tile: str | None = None
|
||||||
upgrade_hash: str | None = ""
|
upgrade_hash: str | None = ""
|
||||||
|
extension_type: str | None = None
|
||||||
|
public_kv_keys: list[str] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def module_name(self) -> str:
|
def module_name(self) -> str:
|
||||||
@@ -170,6 +187,8 @@ class Extension(BaseModel):
|
|||||||
short_description=ext_info.short_description,
|
short_description=ext_info.short_description,
|
||||||
tile=ext_info.icon,
|
tile=ext_info.icon,
|
||||||
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
|
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
|
pay_to_enable: PayToEnableInfo | None = None
|
||||||
payments: list[ReleasePaymentInfo] = []
|
payments: list[ReleasePaymentInfo] = []
|
||||||
dependencies: list[str] = []
|
dependencies: list[str] = []
|
||||||
|
permissions: list[ExtensionPermission] = []
|
||||||
|
extension_type: str | None = "python"
|
||||||
|
public_kv_keys: list[str] = []
|
||||||
archive: str | None = None
|
archive: str | None = None
|
||||||
featured: bool = False
|
featured: bool = False
|
||||||
paid_features: str | None = None
|
paid_features: str | None = None
|
||||||
@@ -454,6 +476,10 @@ class InstallableExtension(BaseModel):
|
|||||||
|
|
||||||
self.name = config_json.get("name")
|
self.name = config_json.get("name")
|
||||||
self.short_description = config_json.get("short_description")
|
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 (
|
if (
|
||||||
self.meta
|
self.meta
|
||||||
@@ -572,6 +598,9 @@ class InstallableExtension(BaseModel):
|
|||||||
latest_release=ExtensionRelease.from_github_release(
|
latest_release=ExtensionRelease.from_github_release(
|
||||||
source_repo, latest_release
|
source_repo, latest_release
|
||||||
),
|
),
|
||||||
|
permissions=config.permissions,
|
||||||
|
extension_type=config.extension_type,
|
||||||
|
public_kv_keys=config.public_kv_keys,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -617,7 +646,10 @@ class InstallableExtension(BaseModel):
|
|||||||
source_repo=f"{conf_path}",
|
source_repo=f"{conf_path}",
|
||||||
min_lnbits_version=config_json.get("min_lnbits_version"),
|
min_lnbits_version=config_json.get("min_lnbits_version"),
|
||||||
max_lnbits_version=config_json.get("max_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).
|
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
|
||||||
Extension must expose a `myextension_stop()` function if it is starting tasks.
|
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)
|
upgrade_hash = settings.extension_upgrade_hash(ext_id)
|
||||||
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
|
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 CAN expose a `myextension_start()` function if it is starting tasks.
|
||||||
Extension MUST expose a `myextension_stop()` in that case.
|
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)
|
upgrade_hash = settings.extension_upgrade_hash(ext_id)
|
||||||
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
|
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from http import HTTPStatus
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from bolt11 import decode as bolt11_decode
|
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 fastapi.requests import Request
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ from lnbits.core.models.extensions import (
|
|||||||
ExtensionReview,
|
ExtensionReview,
|
||||||
ExtensionReviewPaymentRequest,
|
ExtensionReviewPaymentRequest,
|
||||||
ExtensionReviewsStatus,
|
ExtensionReviewsStatus,
|
||||||
|
ExtensionPermissionsGrant,
|
||||||
InstallableExtension,
|
InstallableExtension,
|
||||||
PayToEnableInfo,
|
PayToEnableInfo,
|
||||||
ReleasePaymentInfo,
|
ReleasePaymentInfo,
|
||||||
@@ -174,7 +175,9 @@ async def api_update_pay_to_enable(
|
|||||||
|
|
||||||
@extension_router.put("/{ext_id}/enable")
|
@extension_router.put("/{ext_id}/enable")
|
||||||
async def api_enable_extension(
|
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:
|
) -> SimpleStatus:
|
||||||
if ext_id not in [e.code for e in await get_valid_extensions()]:
|
if ext_id not in [e.code for e in await get_valid_extensions()]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -193,6 +196,27 @@ 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 = (
|
||||||
|
[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:
|
if account_id.is_admin_id or not ext.requires_payment:
|
||||||
user_ext.active = True
|
user_ext.active = True
|
||||||
await update_user_extension(user_ext)
|
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,
|
"isAvailable": ext.id in all_ext_ids,
|
||||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||||
"isActive": ext.id not in inactive_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": (
|
"latestRelease": (
|
||||||
dict(ext.meta.latest_release)
|
dict(ext.meta.latest_release)
|
||||||
if ext.meta and 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(
|
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_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
|
@property
|
||||||
def extension_builder_working_dir_path(self) -> Path:
|
def extension_builder_working_dir_path(self) -> Path:
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ window.PageExtensions = {
|
|||||||
paylinkWebsocket: null,
|
paylinkWebsocket: null,
|
||||||
searchToggle: false,
|
searchToggle: false,
|
||||||
reviewsUrl: null,
|
reviewsUrl: null,
|
||||||
|
permissionsDialog: {
|
||||||
|
show: false,
|
||||||
|
extension: null,
|
||||||
|
checked: []
|
||||||
|
},
|
||||||
reviewsDialog: {
|
reviewsDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
extension: null,
|
extension: null,
|
||||||
@@ -92,6 +97,14 @@ window.PageExtensions = {
|
|||||||
this.filterExtensions(this.searchTerm, val)
|
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: {
|
methods: {
|
||||||
filterExtensions(term, tab) {
|
filterExtensions(term, tab) {
|
||||||
// Filter the extensions list
|
// Filter the extensions list
|
||||||
@@ -241,18 +254,27 @@ window.PageExtensions = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
async enableExtensionForUser(extension) {
|
async enableExtensionForUser(extension) {
|
||||||
|
if (extension.permissions && extension.permissions.length) {
|
||||||
|
this.openPermissionsDialog(extension)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (extension.isPaymentRequired) {
|
if (extension.isPaymentRequired) {
|
||||||
this.showPayToEnable(extension)
|
this.showPayToEnable(extension)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.enableExtension(extension)
|
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
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
'PUT',
|
'PUT',
|
||||||
`/api/v1/extension/${extension.id}/enable`,
|
`/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 => {
|
.then(response => {
|
||||||
this.g.user.extensions = this.g.user.extensions.concat([extension.id])
|
this.g.user.extensions = this.g.user.extensions.concat([extension.id])
|
||||||
@@ -287,13 +309,40 @@ window.PageExtensions = {
|
|||||||
LNbits.utils.notifyApiError(err)
|
LNbits.utils.notifyApiError(err)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
showPayToEnable(extension) {
|
showPayToEnable(extension, permissions) {
|
||||||
this.selectedExtension = extension
|
this.selectedExtension = extension
|
||||||
|
if (permissions) {
|
||||||
|
this.selectedExtension._grantedPermissions = permissions
|
||||||
|
}
|
||||||
this.selectedExtension.payToEnable.paidAmount =
|
this.selectedExtension.payToEnable.paidAmount =
|
||||||
extension.payToEnable.amount
|
extension.payToEnable.amount
|
||||||
this.selectedExtension.payToEnable.showQRCode = false
|
this.selectedExtension.payToEnable.showQRCode = false
|
||||||
this.showPayToEnableDialog = true
|
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) {
|
updatePayToInstallData(extension) {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ def register_invoice_listener(send_chan: asyncio.Queue, name: str | None = None)
|
|||||||
invoice_listeners[name] = send_chan
|
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)
|
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,58 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|||||||
@@ -921,6 +921,46 @@
|
|||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</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-dialog v-model="showExtensionDetailsDialog" position="top">
|
||||||
<q-card
|
<q-card
|
||||||
v-if="selectedExtensionDetails"
|
v-if="selectedExtensionDetails"
|
||||||
|
|||||||
Reference in New Issue
Block a user