This commit is contained in:
Arc
2026-02-21 13:48:27 +00:00
parent 51f98e8130
commit da8a82c78e
5 changed files with 285 additions and 42 deletions
+2 -2
View File
@@ -82,8 +82,8 @@ def _is_wasm_extension(ext: InstallableExtension) -> bool:
config_json = json.load(json_file)
if config_json.get("extension_type") == "wasm":
return True
except Exception:
pass
except Exception as exc:
logger.debug(f"Failed to load extension config for '{ext.id}': {exc!s}")
return False
+91 -26
View File
@@ -22,12 +22,12 @@ from lnbits.core.models.extensions import (
Extension,
ExtensionConfig,
ExtensionMeta,
ExtensionPermission,
ExtensionPermissionsGrant,
ExtensionRelease,
ExtensionReview,
ExtensionReviewPaymentRequest,
ExtensionReviewsStatus,
ExtensionPermission,
ExtensionPermissionsGrant,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
@@ -182,56 +182,104 @@ async def api_enable_extension(
account_id: AccountId = Depends(check_account_id_exists),
grant: ExtensionPermissionsGrant | None = Body(default=None),
) -> SimpleStatus:
await _ensure_extension_exists(ext_id)
logger.info(f"Enabling extension: {ext_id}.")
ext = await _get_installed_active_extension(ext_id)
user_ext = await _get_or_create_user_extension(account_id.id, ext_id)
required_permissions = _get_required_permissions(ext_id, ext)
granted_permissions = _get_granted_permissions(grant, user_ext)
_ensure_permissions(required_permissions, granted_permissions)
if grant and grant.permissions:
await _store_granted_permissions(user_ext, granted_permissions)
if account_id.is_admin_id or not ext.requires_payment:
await _activate_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
return await _enable_paid_extension(ext_id, ext, user_ext)
async def _ensure_extension_exists(ext_id: str) -> None:
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."
)
logger.info(f"Enabling extension: {ext_id}.")
async def _get_installed_active_extension(ext_id: str) -> InstallableExtension:
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.")
return ext
user_ext = await get_user_extension(account_id.id, ext_id)
async def _get_or_create_user_extension(user_id: str, ext_id: str) -> UserExtension:
user_ext = await get_user_extension(user_id, ext_id)
if not user_ext:
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
user_ext = UserExtension(user=user_id, extension=ext_id, active=False)
await create_user_extension(user_ext)
return user_ext
def _get_required_permissions(ext_id: str, ext: InstallableExtension) -> list[str]:
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 = []
return [p.id for p in permissions_source] if permissions_source else []
def _get_granted_permissions(
grant: ExtensionPermissionsGrant | None, user_ext: UserExtension
) -> list[str]:
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
return grant.permissions
if user_ext.extra and user_ext.extra.granted_permissions:
return user_ext.extra.granted_permissions
return []
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.",
)
if account_id.is_admin_id or not ext.requires_payment:
user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
def _ensure_permissions(required: list[str], granted: list[str]) -> None:
if not required:
return
missing = [p for p in required if p not in granted]
if missing:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Missing required permissions to enable this extension.",
)
async def _store_granted_permissions(
user_ext: UserExtension, granted_permissions: list[str]
) -> None:
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)
async def _activate_user_extension(user_ext: UserExtension) -> None:
user_ext.active = True
await update_user_extension(user_ext)
async def _enable_paid_extension(
ext_id: str, ext: InstallableExtension, user_ext: UserExtension
) -> SimpleStatus:
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
)
if user_ext.is_paid:
user_ext.active = True
await update_user_extension(user_ext)
await _activate_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
if not ext.meta or not ext.meta.pay_to_enable or not ext.meta.pay_to_enable.wallet:
@@ -248,9 +296,8 @@ async def api_enable_extension(
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
)
user_ext.active = True
user_ext.extra.paid_to_enable = True
await update_user_extension(user_ext)
await _activate_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
@@ -302,7 +349,9 @@ async def api_update_extension_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 []
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:
@@ -654,6 +703,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
if ext.meta and ext.meta.permissions
else [dict(p) for p in _load_permissions_from_config(ext.id)]
),
"kvSchema": _load_kv_schema_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
@@ -701,6 +751,21 @@ def _load_permissions_from_config(ext_id: str) -> list[ExtensionPermission]:
return []
def _load_kv_schema_from_config(ext_id: str) -> dict:
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)
schema = config_json.get("kv_schema", {})
return schema if isinstance(schema, dict) else {}
except Exception:
return {}
@extension_router.get(
"/reviews/tags",
dependencies=[Depends(check_account_exists)],
+105 -12
View File
@@ -1,12 +1,11 @@
from __future__ import annotations
import asyncio
import json
import time
from pathlib import Path
import httpx
import json
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, Response
from fastapi.staticfiles import StaticFiles
@@ -48,6 +47,59 @@ def _ensure_kv_table(db: Database, ext_id: str) -> str:
return query
_kv_schema_cache: dict[str, dict] = {}
def _load_kv_schema(ext_id: str) -> dict:
if ext_id in _kv_schema_cache:
return _kv_schema_cache[ext_id]
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
_kv_schema_cache[ext_id] = {}
return _kv_schema_cache[ext_id]
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
schema = config_json.get("kv_schema", {})
if not isinstance(schema, dict):
schema = {}
_kv_schema_cache[ext_id] = schema
return schema
except Exception:
_kv_schema_cache[ext_id] = {}
return _kv_schema_cache[ext_id]
def _schema_for_key(schema: dict, key: str) -> dict | None:
if not schema:
return None
entry = schema.get(key)
return entry if isinstance(entry, dict) else None
def _coerce_schema_value(schema_entry: dict, value):
value_type = schema_entry.get("type", "string")
if value_type == "int":
return int(value)
if value_type == "float":
return float(value)
if value_type == "bool":
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() in {"true", "1", "yes", "y"}
return bool(value)
if value_type == "json":
if isinstance(value, (dict, list)):
return value
if isinstance(value, str):
return json.loads(value)
raise ValueError("Invalid json value")
return str(value)
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"
@@ -56,6 +108,17 @@ async def _kv_get(db: Database, ext_id: str, key: str) -> str | None:
{"key": key},
)
if not row:
schema = _load_kv_schema(ext_id)
entry = _schema_for_key(schema, key)
if entry and "default" in entry:
default_value = _coerce_schema_value(entry, entry.get("default"))
stored = (
json.dumps(default_value)
if entry.get("type") == "json"
else str(default_value)
)
await _kv_set(db, ext_id, key, stored)
return stored
return None
return row.get("value")
@@ -88,12 +151,7 @@ async def _require_permission(user_id: str, ext_id: str, permission: str) -> Non
raise HTTPException(403, f"Missing permission: {permission}")
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)"])
proxy_block = f"/{ext_id}/api/v1/proxy"
def _register_pages_routes(router: APIRouter, ext_id: str) -> None:
@router.get("/", response_class=HTMLResponse)
async def index(req: Request, user: User = Depends(check_user_exists)):
try:
@@ -114,6 +172,8 @@ def register_wasm_ext_routes(app, ext) -> None:
except Exception:
return HTMLResponse("Public page not found", status_code=404)
def _register_kv_routes(router: APIRouter, ext_id: str, db: Database, ext) -> None:
@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")
@@ -139,6 +199,16 @@ def register_wasm_ext_routes(app, ext) -> None:
value = payload.get("value")
if value is None:
raise HTTPException(400, "Missing value")
schema = _load_kv_schema(ext_id)
entry = _schema_for_key(schema, key)
if schema and not entry:
raise HTTPException(400, "Key not allowed by schema")
if entry:
try:
coerced = _coerce_schema_value(entry, value)
except Exception:
raise HTTPException(400, "Invalid value for schema") from None
value = json.dumps(coerced) if entry.get("type") == "json" else str(coerced)
await _kv_set(db, ext_id, key, str(value))
await websocket_updater(f"{ext_id}:{key}", str(value))
return {"key": key, "value": value}
@@ -159,6 +229,8 @@ def register_wasm_ext_routes(app, ext) -> None:
await websocket_updater(f"{ext_id}:{key}", str(new_value))
return {"key": key, "value": new_value}
def _register_watch_routes(router: APIRouter, ext_id: str, db: Database, ext) -> None:
@router.post("/api/v1/watch")
async def api_watch_payment(payload: dict, user: User = Depends(check_user_exists)):
payment_hash = payload.get("payment_hash")
@@ -199,11 +271,17 @@ def register_wasm_ext_routes(app, ext) -> None:
finally:
unregister_invoice_listener(queue_name)
asyncio.create_task(_watch())
return {"ok": True}
task = asyncio.create_task(_watch())
return {"ok": True, "task_id": id(task)}
def _register_proxy_routes(
router: APIRouter, app, ext_id: str, proxy_block: str
) -> None:
@router.post("/api/v1/proxy")
async def api_proxy(payload: dict, req: Request, user: User = Depends(check_user_exists)):
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:
@@ -233,7 +311,9 @@ def register_wasm_ext_routes(app, ext) -> None:
media_type=resp.headers.get("content-type"),
)
static_dir = _ext_static_dir(ext_id, ext.upgrade_hash)
def _mount_static(app, ext_id: str, upgrade_hash: str | None) -> None:
static_dir = _ext_static_dir(ext_id, upgrade_hash)
if static_dir.is_dir():
app.mount(
f"/{ext_id}/static",
@@ -241,6 +321,19 @@ def register_wasm_ext_routes(app, ext) -> None:
name=f"{ext_id}_static",
)
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)"])
proxy_block = f"/{ext_id}/api/v1/proxy"
_register_pages_routes(router, ext_id)
_register_kv_routes(router, ext_id, db, ext)
_register_watch_routes(router, ext_id, db, ext)
_register_proxy_routes(router, app, ext_id, proxy_block)
_mount_static(app, ext_id, ext.upgrade_hash)
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash else ""
app.include_router(router, prefix=prefix)
+68 -2
View File
@@ -18,6 +18,54 @@ from wasmtime import (
from lnbits.db import Database
from lnbits.settings import settings
_kv_schema_cache: dict[str, dict] = {}
def _load_kv_schema(ext_id: str) -> dict:
if ext_id in _kv_schema_cache:
return _kv_schema_cache[ext_id]
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
_kv_schema_cache[ext_id] = {}
return _kv_schema_cache[ext_id]
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
schema = config_json.get("kv_schema", {})
if not isinstance(schema, dict):
schema = {}
_kv_schema_cache[ext_id] = schema
return schema
except Exception:
_kv_schema_cache[ext_id] = {}
return _kv_schema_cache[ext_id]
def _schema_for_key(schema: dict, key: str) -> dict | None:
if not schema:
return None
entry = schema.get(key)
return entry if isinstance(entry, dict) else None
def _coerce_schema_value(schema_entry: dict, value: str):
value_type = schema_entry.get("type", "string")
if value_type == "int":
return int(value)
if value_type == "float":
return float(value)
if value_type == "bool":
if value.lower() in {"true", "1", "yes", "y"}:
return True
if value.lower() in {"false", "0", "no", "n"}:
return False
raise ValueError("Invalid bool value")
if value_type == "json":
return json.loads(value)
return value
def _run(coro):
return asyncio.run(coro)
@@ -62,7 +110,11 @@ def _db_get(
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}))
row = _run(
db.fetchone(
f"SELECT value FROM {table} WHERE key = :key", {"key": key}
)
)
if not row:
return 0
value = str(row.get("value", ""))
@@ -82,9 +134,23 @@ def _db_set(
) -> int:
key = _read_bytes(caller, key_ptr, key_len).decode(errors="ignore")
value = _read_bytes(caller, val_ptr, val_len).decode(errors="ignore")
schema = _load_kv_schema(ext_id)
entry = _schema_for_key(schema, key)
if schema and not entry:
raise RuntimeError("Key not allowed by schema")
if entry:
try:
coerced = _coerce_schema_value(entry, value)
except Exception as exc:
raise RuntimeError("Invalid value for schema") from exc
value = json.dumps(coerced) if entry.get("type") == "json" else str(coerced)
_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}))
row = _run(
db.fetchone(
f"SELECT key FROM {table} WHERE key = :key", {"key": key}
)
)
if row:
_run(
db.execute(
+19
View File
@@ -954,6 +954,25 @@
</q-item-section>
</q-item>
</q-list>
<div
v-if="
permissionsDialog.extension &&
permissionsDialog.extension.kvSchema &&
Object.keys(permissionsDialog.extension.kvSchema).length
"
class="q-mt-md"
>
<div class="text-caption text-grey">KV keys defined:</div>
<q-chip
v-for="(value, key) in permissionsDialog.extension.kvSchema"
:key="key"
color="grey-3"
text-color="black"
class="q-mr-xs q-mt-xs"
>
{{ key }}
</q-chip>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn