bug fixes
This commit is contained in:
@@ -86,6 +86,7 @@ class ExtensionConfig(BaseModel):
|
|||||||
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] = []
|
||||||
|
public_wasm_functions: 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)
|
||||||
@@ -164,6 +165,7 @@ class Extension(BaseModel):
|
|||||||
upgrade_hash: str | None = ""
|
upgrade_hash: str | None = ""
|
||||||
extension_type: str | None = None
|
extension_type: str | None = None
|
||||||
public_kv_keys: list[str] = []
|
public_kv_keys: list[str] = []
|
||||||
|
public_wasm_functions: list[str] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def module_name(self) -> str:
|
def module_name(self) -> str:
|
||||||
@@ -189,6 +191,9 @@ class Extension(BaseModel):
|
|||||||
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,
|
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 [],
|
public_kv_keys=ext_info.meta.public_kv_keys if ext_info.meta else [],
|
||||||
|
public_wasm_functions=ext_info.meta.public_wasm_functions
|
||||||
|
if ext_info.meta
|
||||||
|
else [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -353,6 +358,7 @@ class ExtensionMeta(BaseModel):
|
|||||||
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] = []
|
||||||
|
public_wasm_functions: 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
|
||||||
@@ -480,6 +486,9 @@ class InstallableExtension(BaseModel):
|
|||||||
self.meta.permissions = config_json.get("permissions", [])
|
self.meta.permissions = config_json.get("permissions", [])
|
||||||
self.meta.extension_type = config_json.get("extension_type", "python")
|
self.meta.extension_type = config_json.get("extension_type", "python")
|
||||||
self.meta.public_kv_keys = config_json.get("public_kv_keys", [])
|
self.meta.public_kv_keys = config_json.get("public_kv_keys", [])
|
||||||
|
self.meta.public_wasm_functions = config_json.get(
|
||||||
|
"public_wasm_functions", []
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
self.meta
|
self.meta
|
||||||
@@ -601,6 +610,7 @@ class InstallableExtension(BaseModel):
|
|||||||
permissions=config.permissions,
|
permissions=config.permissions,
|
||||||
extension_type=config.extension_type,
|
extension_type=config.extension_type,
|
||||||
public_kv_keys=config.public_kv_keys,
|
public_kv_keys=config.public_kv_keys,
|
||||||
|
public_wasm_functions=config.public_wasm_functions,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -650,6 +660,9 @@ class InstallableExtension(BaseModel):
|
|||||||
permissions=config_json.get("permissions", []),
|
permissions=config_json.get("permissions", []),
|
||||||
extension_type=config_json.get("extension_type", "python"),
|
extension_type=config_json.get("extension_type", "python"),
|
||||||
public_kv_keys=config_json.get("public_kv_keys", []),
|
public_kv_keys=config_json.get("public_kv_keys", []),
|
||||||
|
public_wasm_functions=config_json.get(
|
||||||
|
"public_wasm_functions", []
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from lnbits.core.crud.extensions import (
|
|||||||
update_installed_extension,
|
update_installed_extension,
|
||||||
)
|
)
|
||||||
from lnbits.core.helpers import migrate_extension_database
|
from lnbits.core.helpers import migrate_extension_database
|
||||||
from lnbits.db import Connection
|
from lnbits.db import Connection, Database, COCKROACH, POSTGRES
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
||||||
@@ -74,10 +74,24 @@ async def uninstall_extension(ext_id: str):
|
|||||||
|
|
||||||
extension = await get_installed_extension(ext_id)
|
extension = await get_installed_extension(ext_id)
|
||||||
if extension:
|
if extension:
|
||||||
|
if extension.meta and extension.meta.extension_type == "wasm":
|
||||||
|
await _purge_wasm_extension_db(ext_id)
|
||||||
extension.clean_extension_files()
|
extension.clean_extension_files()
|
||||||
await delete_installed_extension(ext_id=ext_id)
|
await delete_installed_extension(ext_id=ext_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _purge_wasm_extension_db(ext_id: str) -> None:
|
||||||
|
cleaned = await Database.clean_ext_db_files(ext_id)
|
||||||
|
if cleaned:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
db = Database(f"ext_{ext_id}")
|
||||||
|
if db.type in {POSTGRES, COCKROACH}:
|
||||||
|
await db.execute(f"DROP SCHEMA IF EXISTS {ext_id} CASCADE") # noqa: S608
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Failed to drop WASM extension schema for '{ext_id}': {exc}")
|
||||||
|
|
||||||
|
|
||||||
async def activate_extension(ext: Extension):
|
async def activate_extension(ext: Extension):
|
||||||
core_app_extra.register_new_ext_routes(ext)
|
core_app_extra.register_new_ext_routes(ext)
|
||||||
await update_installed_extension_state(ext_id=ext.code, active=True)
|
await update_installed_extension_state(ext_id=ext.code, active=True)
|
||||||
|
|||||||
@@ -49,6 +49,17 @@ def _ensure_kv_table(db: Database, ext_id: str) -> str:
|
|||||||
return query
|
return query
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_secret_kv_table(db: Database, ext_id: str) -> str:
|
||||||
|
table = _secret_kv_table_name(db, ext_id)
|
||||||
|
query = f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS {table} (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
_kv_schema_cache: dict[str, dict] = {}
|
_kv_schema_cache: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
@@ -62,6 +73,16 @@ def _kv_table_name(db: Database, ext_id: str) -> str:
|
|||||||
return table
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_kv_table_name(db: Database, ext_id: str) -> str:
|
||||||
|
table = f"{ext_id}.secret_kv" if db.schema else "secret_kv"
|
||||||
|
if (
|
||||||
|
re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", table)
|
||||||
|
is None
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid secret KV table name")
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
def _load_kv_schema(ext_id: str) -> dict:
|
def _load_kv_schema(ext_id: str) -> dict:
|
||||||
if ext_id in _kv_schema_cache:
|
if ext_id in _kv_schema_cache:
|
||||||
return _kv_schema_cache[ext_id]
|
return _kv_schema_cache[ext_id]
|
||||||
@@ -84,6 +105,21 @@ def _load_kv_schema(ext_id: str) -> dict:
|
|||||||
return _kv_schema_cache[ext_id]
|
return _kv_schema_cache[ext_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_public_wasm_functions(ext_id: str) -> list[str]:
|
||||||
|
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)
|
||||||
|
funcs = config_json.get("public_wasm_functions", [])
|
||||||
|
return funcs if isinstance(funcs, list) else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _schema_for_key(schema: dict, key: str) -> dict | None:
|
def _schema_for_key(schema: dict, key: str) -> dict | None:
|
||||||
if not schema:
|
if not schema:
|
||||||
return None
|
return None
|
||||||
@@ -154,6 +190,46 @@ async def _kv_set(db: Database, ext_id: str, key: str, value: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _secret_kv_get(db: Database, ext_id: str, key: str) -> str | None:
|
||||||
|
await db.execute(_ensure_secret_kv_table(db, ext_id))
|
||||||
|
table = _secret_kv_table_name(db, ext_id)
|
||||||
|
row: dict[str, Any] | None = await db.fetchone(
|
||||||
|
f"SELECT value FROM {table} WHERE key = :key", # noqa: S608
|
||||||
|
{"key": key},
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return row.get("value")
|
||||||
|
|
||||||
|
|
||||||
|
async def _secret_kv_set(db: Database, ext_id: str, key: str, value: str) -> None:
|
||||||
|
await db.execute(_ensure_secret_kv_table(db, ext_id))
|
||||||
|
table = _secret_kv_table_name(db, ext_id)
|
||||||
|
existing: dict[str, Any] | None = await db.fetchone(
|
||||||
|
f"SELECT key FROM {table} WHERE key = :key", # noqa: S608
|
||||||
|
{"key": key},
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
await db.execute(
|
||||||
|
f"UPDATE {table} SET value = :value WHERE key = :key", # noqa: S608
|
||||||
|
{"key": key, "value": value},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await db.execute(
|
||||||
|
f"INSERT INTO {table} (key, value) VALUES (:key, :value)", # noqa: S608
|
||||||
|
{"key": key, "value": value},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _secret_kv_delete(db: Database, ext_id: str, key: str) -> None:
|
||||||
|
await db.execute(_ensure_secret_kv_table(db, ext_id))
|
||||||
|
table = _secret_kv_table_name(db, ext_id)
|
||||||
|
await db.execute(
|
||||||
|
f"DELETE FROM {table} WHERE key = :key", # noqa: S608
|
||||||
|
{"key": key},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _require_permission(user_id: str, ext_id: str, permission: str) -> None:
|
async def _require_permission(user_id: str, ext_id: str, permission: str) -> None:
|
||||||
user_ext = await get_user_extension(user_id, ext_id)
|
user_ext = await get_user_extension(user_id, ext_id)
|
||||||
if not user_ext or not user_ext.active:
|
if not user_ext or not user_ext.active:
|
||||||
@@ -189,6 +265,87 @@ def _register_kv_routes(router: APIRouter, ext_id: str, db: Database, ext) -> No
|
|||||||
_register_kv_read_routes(router, ext_id, db, ext)
|
_register_kv_read_routes(router, ext_id, db, ext)
|
||||||
_register_kv_write_routes(router, ext_id, db)
|
_register_kv_write_routes(router, ext_id, db)
|
||||||
_register_kv_increment_route(router, ext_id, db, ext)
|
_register_kv_increment_route(router, ext_id, db, ext)
|
||||||
|
_register_secret_routes(router, ext_id, db)
|
||||||
|
|
||||||
|
|
||||||
|
def _register_secret_routes(router: APIRouter, ext_id: str, db: Database) -> None:
|
||||||
|
@router.post("/api/v1/secret/{key}")
|
||||||
|
async def api_secret_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 _secret_kv_set(db, ext_id, key, str(value))
|
||||||
|
return {"key": key}
|
||||||
|
|
||||||
|
@router.delete("/api/v1/secret/{key}")
|
||||||
|
async def api_secret_delete(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)
|
||||||
|
await _secret_kv_delete(db, ext_id, key)
|
||||||
|
return {"key": key}
|
||||||
|
|
||||||
|
|
||||||
|
def _register_public_call_routes(
|
||||||
|
router: APIRouter, ext_id: str, db: Database, ext
|
||||||
|
) -> None:
|
||||||
|
@router.post("/api/v1/public/call/{handler}")
|
||||||
|
async def api_public_wasm_call(handler: str, payload: dict):
|
||||||
|
funcs = getattr(ext, "public_wasm_functions", None) or _load_public_wasm_functions(
|
||||||
|
ext_id
|
||||||
|
)
|
||||||
|
if handler not in funcs:
|
||||||
|
raise HTTPException(404, "Handler not public")
|
||||||
|
_check_quota("public", ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min)
|
||||||
|
|
||||||
|
request_id = int(time.time() * 1000) % 2147483647
|
||||||
|
raw = payload.get("raw")
|
||||||
|
value = raw if isinstance(raw, str) else json.dumps(payload)
|
||||||
|
await _kv_set(db, ext_id, f"public_request:{request_id}", value)
|
||||||
|
await _kv_set(db, ext_id, "public_request", value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await wasm_call(
|
||||||
|
ext_id, handler, [request_id], upgrade_hash=ext.upgrade_hash
|
||||||
|
)
|
||||||
|
except WasmExecutionError as exc:
|
||||||
|
raise HTTPException(500, str(exc)) from exc
|
||||||
|
|
||||||
|
response = await _kv_get(db, ext_id, f"public_response:{request_id}")
|
||||||
|
if response is None:
|
||||||
|
response = await _kv_get(db, ext_id, "public_response")
|
||||||
|
if response is None:
|
||||||
|
raise HTTPException(500, "No response")
|
||||||
|
try:
|
||||||
|
data = json.loads(response)
|
||||||
|
except Exception:
|
||||||
|
return {"raw": response}
|
||||||
|
|
||||||
|
watch = payload.get("watch") if isinstance(payload, dict) else None
|
||||||
|
if isinstance(watch, dict) and isinstance(data, dict):
|
||||||
|
payment_hash = data.get("payment_hash")
|
||||||
|
store_key = watch.get("store_key")
|
||||||
|
tag = watch.get("tag")
|
||||||
|
handler_name = watch.get("handler") or "noop"
|
||||||
|
if (
|
||||||
|
isinstance(payment_hash, str)
|
||||||
|
and isinstance(store_key, str)
|
||||||
|
and handler_name in funcs
|
||||||
|
):
|
||||||
|
_start_payment_watch(
|
||||||
|
ext_id,
|
||||||
|
db,
|
||||||
|
payment_hash,
|
||||||
|
handler_name,
|
||||||
|
tag if isinstance(tag, str) else None,
|
||||||
|
store_key,
|
||||||
|
ext.upgrade_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _register_kv_read_routes(router: APIRouter, ext_id: str, db: Database, ext) -> None:
|
def _register_kv_read_routes(router: APIRouter, ext_id: str, db: Database, ext) -> None:
|
||||||
@@ -265,40 +422,53 @@ def _register_watch_routes(router: APIRouter, ext_id: str, db: Database, ext) ->
|
|||||||
raise HTTPException(400, "Missing payment_hash")
|
raise HTTPException(400, "Missing payment_hash")
|
||||||
await _require_permission(user.id, ext_id, "ext.payments.watch")
|
await _require_permission(user.id, ext_id, "ext.payments.watch")
|
||||||
await _require_permission(user.id, ext_id, "ext.db.read_write")
|
await _require_permission(user.id, ext_id, "ext.db.read_write")
|
||||||
|
task = _start_payment_watch(
|
||||||
queue_name = f"wasm:{ext_id}:{payment_hash}:{time.time()}"
|
ext_id, db, payment_hash, handler, tag, store_key, ext.upgrade_hash
|
||||||
invoice_queue: asyncio.Queue = asyncio.Queue()
|
)
|
||||||
register_invoice_listener(invoice_queue, queue_name)
|
|
||||||
|
|
||||||
async def _watch():
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
payment = await invoice_queue.get()
|
|
||||||
if payment.payment_hash != payment_hash:
|
|
||||||
continue
|
|
||||||
if tag:
|
|
||||||
extra = payment.extra or {}
|
|
||||||
if extra.get("tag") != tag:
|
|
||||||
continue
|
|
||||||
if payment.pending is False:
|
|
||||||
payload_json = json.dumps(payment.dict(exclude={"preimage"}))
|
|
||||||
await _kv_set(db, ext_id, store_key, payload_json)
|
|
||||||
await wasm_call(
|
|
||||||
ext_id,
|
|
||||||
handler,
|
|
||||||
[],
|
|
||||||
upgrade_hash=ext.upgrade_hash,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
finally:
|
|
||||||
unregister_invoice_listener(queue_name)
|
|
||||||
|
|
||||||
task = asyncio.create_task(_watch())
|
|
||||||
return {"ok": True, "task_id": id(task)}
|
return {"ok": True, "task_id": id(task)}
|
||||||
|
|
||||||
|
|
||||||
|
def _start_payment_watch(
|
||||||
|
ext_id: str,
|
||||||
|
db: Database,
|
||||||
|
payment_hash: str,
|
||||||
|
handler: str,
|
||||||
|
tag: str | None,
|
||||||
|
store_key: str,
|
||||||
|
upgrade_hash: str | None,
|
||||||
|
) -> asyncio.Task:
|
||||||
|
queue_name = f"wasm:{ext_id}:{payment_hash}:{time.time()}"
|
||||||
|
invoice_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
register_invoice_listener(invoice_queue, queue_name)
|
||||||
|
|
||||||
|
async def _watch():
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
payment = await invoice_queue.get()
|
||||||
|
if payment.payment_hash != payment_hash:
|
||||||
|
continue
|
||||||
|
if tag:
|
||||||
|
extra = payment.extra or {}
|
||||||
|
if extra.get("tag") != tag:
|
||||||
|
continue
|
||||||
|
if payment.pending is False:
|
||||||
|
payload_json = json.dumps(payment.dict(exclude={"preimage"}))
|
||||||
|
await _kv_set(db, ext_id, store_key, payload_json)
|
||||||
|
await wasm_call(
|
||||||
|
ext_id,
|
||||||
|
handler,
|
||||||
|
[],
|
||||||
|
upgrade_hash=upgrade_hash,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
unregister_invoice_listener(queue_name)
|
||||||
|
|
||||||
|
return asyncio.create_task(_watch())
|
||||||
|
|
||||||
|
|
||||||
def _register_proxy_routes(
|
def _register_proxy_routes(
|
||||||
router: APIRouter, app, ext_id: str, proxy_block: str
|
router: APIRouter, app, ext_id: str, proxy_block: str
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -354,6 +524,7 @@ def register_wasm_ext_routes(app, ext) -> None:
|
|||||||
|
|
||||||
_register_pages_routes(router, ext_id)
|
_register_pages_routes(router, ext_id)
|
||||||
_register_kv_routes(router, ext_id, db, ext)
|
_register_kv_routes(router, ext_id, db, ext)
|
||||||
|
_register_public_call_routes(router, ext_id, db, ext)
|
||||||
_register_watch_routes(router, ext_id, db, ext)
|
_register_watch_routes(router, ext_id, db, ext)
|
||||||
_register_proxy_routes(router, app, ext_id, proxy_block)
|
_register_proxy_routes(router, app, ext_id, proxy_block)
|
||||||
_mount_static(app, ext_id, ext.upgrade_hash)
|
_mount_static(app, ext_id, ext.upgrade_hash)
|
||||||
|
|||||||
+298
-5
@@ -2,9 +2,11 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
|
import httpx
|
||||||
from wasmtime import (
|
from wasmtime import (
|
||||||
Caller,
|
Caller,
|
||||||
Config,
|
Config,
|
||||||
@@ -18,9 +20,11 @@ from wasmtime import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from lnbits.db import Database
|
from lnbits.db import Database
|
||||||
|
from lnbits.core.services import websocket_updater
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
_kv_schema_cache: dict[str, dict] = {}
|
_kv_schema_cache: dict[str, dict] = {}
|
||||||
|
_http_permissions_cache: dict[str, set[tuple[str, str]]] = {}
|
||||||
|
|
||||||
|
|
||||||
def _load_kv_schema(ext_id: str) -> dict:
|
def _load_kv_schema(ext_id: str) -> dict:
|
||||||
@@ -52,6 +56,42 @@ def _schema_for_key(schema: dict, key: str) -> dict | None:
|
|||||||
return entry if isinstance(entry, dict) else None
|
return entry if isinstance(entry, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_http_permissions(ext_id: str) -> set[tuple[str, str]]:
|
||||||
|
if ext_id in _http_permissions_cache:
|
||||||
|
return _http_permissions_cache[ext_id]
|
||||||
|
try:
|
||||||
|
conf_path = Path(
|
||||||
|
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
|
||||||
|
)
|
||||||
|
if not conf_path.is_file():
|
||||||
|
_http_permissions_cache[ext_id] = set()
|
||||||
|
return _http_permissions_cache[ext_id]
|
||||||
|
with open(conf_path, "r+") as json_file:
|
||||||
|
config_json = json.load(json_file)
|
||||||
|
permissions = config_json.get("permissions", [])
|
||||||
|
allowed: set[tuple[str, str]] = set()
|
||||||
|
if isinstance(permissions, list):
|
||||||
|
for perm in permissions:
|
||||||
|
perm_id = perm.get("id") if isinstance(perm, dict) else None
|
||||||
|
if not isinstance(perm_id, str):
|
||||||
|
continue
|
||||||
|
if not perm_id.startswith("api."):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
method_part, path = perm_id.split(":", 1)
|
||||||
|
method = method_part.replace("api.", "").upper()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not path.startswith("/"):
|
||||||
|
continue
|
||||||
|
allowed.add((method, path))
|
||||||
|
_http_permissions_cache[ext_id] = allowed
|
||||||
|
return allowed
|
||||||
|
except Exception:
|
||||||
|
_http_permissions_cache[ext_id] = set()
|
||||||
|
return _http_permissions_cache[ext_id]
|
||||||
|
|
||||||
|
|
||||||
def _coerce_schema_value(schema_entry: dict, value: str):
|
def _coerce_schema_value(schema_entry: dict, value: str):
|
||||||
value_type = schema_entry.get("type", "string")
|
value_type = schema_entry.get("type", "string")
|
||||||
if value_type == "int":
|
if value_type == "int":
|
||||||
@@ -83,6 +123,16 @@ def _ensure_kv_table(db: Database, ext_id: str) -> str:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_secret_kv_table(db: Database, ext_id: str) -> str:
|
||||||
|
table = _secret_kv_table_name(db, ext_id)
|
||||||
|
return f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS {table} (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _kv_table_name(db: Database, ext_id: str) -> str:
|
def _kv_table_name(db: Database, ext_id: str) -> str:
|
||||||
table = f"{ext_id}.kv" if db.schema else "kv"
|
table = f"{ext_id}.kv" if db.schema else "kv"
|
||||||
if (
|
if (
|
||||||
@@ -93,8 +143,25 @@ def _kv_table_name(db: Database, ext_id: str) -> str:
|
|||||||
return table
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_kv_table_name(db: Database, ext_id: str) -> str:
|
||||||
|
table = f"{ext_id}.secret_kv" if db.schema else "secret_kv"
|
||||||
|
if (
|
||||||
|
re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?", table)
|
||||||
|
is None
|
||||||
|
):
|
||||||
|
raise RuntimeError("Invalid secret KV table name")
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
def _get_memory(caller: Caller):
|
def _get_memory(caller: Caller):
|
||||||
memory = caller.get_export("memory") # type: ignore[attr-defined]
|
memory = None
|
||||||
|
if hasattr(caller, "get_export"):
|
||||||
|
memory = caller.get_export("memory") # type: ignore[attr-defined]
|
||||||
|
if memory is None:
|
||||||
|
try:
|
||||||
|
memory = caller.get("memory")
|
||||||
|
except Exception:
|
||||||
|
memory = None
|
||||||
if memory is None:
|
if memory is None:
|
||||||
raise RuntimeError("WASM module does not export memory")
|
raise RuntimeError("WASM module does not export memory")
|
||||||
return memory
|
return memory
|
||||||
@@ -107,7 +174,71 @@ def _read_bytes(caller: Caller, ptr: int, length: int) -> bytes:
|
|||||||
|
|
||||||
def _write_bytes(caller: Caller, ptr: int, data: bytes) -> None:
|
def _write_bytes(caller: Caller, ptr: int, data: bytes) -> None:
|
||||||
memory = _get_memory(caller)
|
memory = _get_memory(caller)
|
||||||
memory.write(caller, data, ptr)
|
if isinstance(data, int):
|
||||||
|
data = str(data).encode()
|
||||||
|
elif isinstance(data, str):
|
||||||
|
data = data.encode()
|
||||||
|
try:
|
||||||
|
memory.write(caller, data, ptr)
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(f"memory.write failed for type={type(data)}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def _ws_publish(ext_id: str, topic: str, payload: str) -> int:
|
||||||
|
if not topic.startswith(f"{ext_id}:"):
|
||||||
|
raise RuntimeError("WS topic must be namespaced to extension")
|
||||||
|
await websocket_updater(topic, payload)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _http_request(
|
||||||
|
ext_id: str,
|
||||||
|
caller: Caller,
|
||||||
|
method_ptr: int,
|
||||||
|
method_len: int,
|
||||||
|
path_ptr: int,
|
||||||
|
path_len: int,
|
||||||
|
body_ptr: int,
|
||||||
|
body_len: int,
|
||||||
|
key_ptr: int,
|
||||||
|
key_len: int,
|
||||||
|
out_ptr: int,
|
||||||
|
out_len: int,
|
||||||
|
) -> int:
|
||||||
|
method = _read_bytes(caller, method_ptr, method_len).decode(errors="ignore").upper()
|
||||||
|
path = _read_bytes(caller, path_ptr, path_len).decode(errors="ignore")
|
||||||
|
body = _read_bytes(caller, body_ptr, body_len)
|
||||||
|
if isinstance(body, int):
|
||||||
|
body = str(body).encode()
|
||||||
|
elif isinstance(body, str):
|
||||||
|
body = body.encode()
|
||||||
|
else:
|
||||||
|
body = bytes(body)
|
||||||
|
api_key = _read_bytes(caller, key_ptr, key_len).decode(errors="ignore")
|
||||||
|
|
||||||
|
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
|
||||||
|
raise RuntimeError("Unsupported method")
|
||||||
|
if not path.startswith("/") or "://" in path:
|
||||||
|
raise RuntimeError("Invalid path")
|
||||||
|
|
||||||
|
allowed = _load_http_permissions(ext_id)
|
||||||
|
if (method, path) not in allowed:
|
||||||
|
raise RuntimeError("HTTP permission denied")
|
||||||
|
|
||||||
|
base_url = settings.lnbits_baseurl.rstrip("/")
|
||||||
|
headers = {"accept": "application/json"}
|
||||||
|
if body_len > 0:
|
||||||
|
headers["content-type"] = "application/json"
|
||||||
|
if api_key:
|
||||||
|
headers["x-api-key"] = api_key
|
||||||
|
|
||||||
|
with httpx.Client(base_url=base_url) as client:
|
||||||
|
resp = client.request(method, path, headers=headers, content=body)
|
||||||
|
data = resp.content or b""
|
||||||
|
if out_len > 0:
|
||||||
|
data = data[: max(0, out_len)]
|
||||||
|
_write_bytes(caller, out_ptr, data)
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
|
||||||
def _db_get(
|
def _db_get(
|
||||||
@@ -182,12 +313,74 @@ def _db_set(
|
|||||||
return len(value)
|
return len(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_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_secret_kv_table(db, ext_id)))
|
||||||
|
table = _secret_kv_table_name(db, ext_id)
|
||||||
|
row = _run(
|
||||||
|
db.fetchone(
|
||||||
|
f"SELECT value FROM {table} WHERE key = :key", # noqa: S608
|
||||||
|
{"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 _secret_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_secret_kv_table(db, ext_id)))
|
||||||
|
table = _secret_kv_table_name(db, ext_id)
|
||||||
|
row = _run(
|
||||||
|
db.fetchone(
|
||||||
|
f"SELECT key FROM {table} WHERE key = :key", # noqa: S608
|
||||||
|
{"key": key},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
_run(
|
||||||
|
db.execute(
|
||||||
|
f"UPDATE {table} SET value = :value WHERE key = :key", # noqa: S608
|
||||||
|
{"key": key, "value": value},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_run(
|
||||||
|
db.execute(
|
||||||
|
f"INSERT INTO {table} (key, value) VALUES (:key, :value)", # noqa: S608
|
||||||
|
{"key": key, "value": value},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return len(value)
|
||||||
|
|
||||||
|
|
||||||
def _load_module(module_path: Path, ext_id: str):
|
def _load_module(module_path: Path, ext_id: str):
|
||||||
config = Config()
|
config = Config()
|
||||||
config.consume_fuel = True
|
config.consume_fuel = settings.lnbits_wasm_fuel > 0
|
||||||
engine = Engine(config)
|
engine = Engine(config)
|
||||||
store = Store(engine)
|
store = Store(engine)
|
||||||
if hasattr(store, "add_fuel"):
|
if settings.lnbits_wasm_fuel > 0 and hasattr(store, "add_fuel"):
|
||||||
store.add_fuel(settings.lnbits_wasm_fuel) # type: ignore[attr-defined]
|
store.add_fuel(settings.lnbits_wasm_fuel) # type: ignore[attr-defined]
|
||||||
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}")
|
||||||
@@ -202,6 +395,16 @@ def _load_module(module_path: Path, ext_id: str):
|
|||||||
) -> int:
|
) -> int:
|
||||||
return _db_set(db, ext_id, caller, key_ptr, key_len, val_ptr, val_len)
|
return _db_set(db, ext_id, caller, key_ptr, key_len, val_ptr, val_len)
|
||||||
|
|
||||||
|
def db_secret_get(
|
||||||
|
caller: Caller, key_ptr: int, key_len: int, out_ptr: int, out_len: int
|
||||||
|
) -> int:
|
||||||
|
return _secret_db_get(db, ext_id, caller, key_ptr, key_len, out_ptr, out_len)
|
||||||
|
|
||||||
|
def db_secret_set(
|
||||||
|
caller: Caller, key_ptr: int, key_len: int, val_ptr: int, val_len: int
|
||||||
|
) -> int:
|
||||||
|
return _secret_db_set(db, ext_id, caller, key_ptr, key_len, val_ptr, val_len)
|
||||||
|
|
||||||
def linker_define(linker: Linker, module: str, name: str, func: Func) -> None:
|
def linker_define(linker: Linker, module: str, name: str, func: Func) -> None:
|
||||||
try:
|
try:
|
||||||
linker.define(store, module, name, func)
|
linker.define(store, module, name, func)
|
||||||
@@ -220,6 +423,7 @@ def _load_module(module_path: Path, ext_id: str):
|
|||||||
[ValType.i32()],
|
[ValType.i32()],
|
||||||
),
|
),
|
||||||
db_get,
|
db_get,
|
||||||
|
access_caller=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
linker_define(
|
linker_define(
|
||||||
@@ -233,6 +437,95 @@ def _load_module(module_path: Path, ext_id: str):
|
|||||||
[ValType.i32()],
|
[ValType.i32()],
|
||||||
),
|
),
|
||||||
db_set,
|
db_set,
|
||||||
|
access_caller=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
linker_define(
|
||||||
|
linker,
|
||||||
|
"host",
|
||||||
|
"db_secret_get",
|
||||||
|
Func(
|
||||||
|
store,
|
||||||
|
FuncType(
|
||||||
|
[ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()],
|
||||||
|
[ValType.i32()],
|
||||||
|
),
|
||||||
|
db_secret_get,
|
||||||
|
access_caller=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
linker_define(
|
||||||
|
linker,
|
||||||
|
"host",
|
||||||
|
"db_secret_set",
|
||||||
|
Func(
|
||||||
|
store,
|
||||||
|
FuncType(
|
||||||
|
[ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()],
|
||||||
|
[ValType.i32()],
|
||||||
|
),
|
||||||
|
db_secret_set,
|
||||||
|
access_caller=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
linker_define(
|
||||||
|
linker,
|
||||||
|
"host",
|
||||||
|
"http_request",
|
||||||
|
Func(
|
||||||
|
store,
|
||||||
|
FuncType(
|
||||||
|
[
|
||||||
|
ValType.i32(),
|
||||||
|
ValType.i32(),
|
||||||
|
ValType.i32(),
|
||||||
|
ValType.i32(),
|
||||||
|
ValType.i32(),
|
||||||
|
ValType.i32(),
|
||||||
|
ValType.i32(),
|
||||||
|
ValType.i32(),
|
||||||
|
ValType.i32(),
|
||||||
|
ValType.i32(),
|
||||||
|
],
|
||||||
|
[ValType.i32()],
|
||||||
|
),
|
||||||
|
lambda caller, method_ptr, method_len, path_ptr, path_len, body_ptr, body_len, key_ptr, key_len, out_ptr, out_len: _http_request(
|
||||||
|
ext_id,
|
||||||
|
caller,
|
||||||
|
method_ptr,
|
||||||
|
method_len,
|
||||||
|
path_ptr,
|
||||||
|
path_len,
|
||||||
|
body_ptr,
|
||||||
|
body_len,
|
||||||
|
key_ptr,
|
||||||
|
key_len,
|
||||||
|
out_ptr,
|
||||||
|
out_len,
|
||||||
|
),
|
||||||
|
access_caller=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
linker_define(
|
||||||
|
linker,
|
||||||
|
"host",
|
||||||
|
"ws_publish",
|
||||||
|
Func(
|
||||||
|
store,
|
||||||
|
FuncType(
|
||||||
|
[ValType.i32(), ValType.i32(), ValType.i32(), ValType.i32()],
|
||||||
|
[ValType.i32()],
|
||||||
|
),
|
||||||
|
lambda caller, topic_ptr, topic_len, payload_ptr, payload_len: _run(
|
||||||
|
_ws_publish(
|
||||||
|
ext_id,
|
||||||
|
_read_bytes(caller, topic_ptr, topic_len)
|
||||||
|
.decode(errors="ignore"),
|
||||||
|
_read_bytes(caller, payload_ptr, payload_len)
|
||||||
|
.decode(errors="ignore"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
access_caller=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -268,7 +561,7 @@ def main() -> int:
|
|||||||
else:
|
else:
|
||||||
result = func(store, *int_args) # type: ignore[operator]
|
result = func(store, *int_args) # type: ignore[operator]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
payload = {"ok": False, "error": str(exc)}
|
payload = {"ok": False, "error": f"{exc}\n{traceback.format_exc()}"}
|
||||||
sys.stdout.write(json.dumps(payload))
|
sys.stdout.write(json.dumps(payload))
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@ class ExtensionsSettings(LNbitsSettings):
|
|||||||
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=3.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=0)
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user