black
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user