diff --git a/lnbits/app.py b/lnbits/app.py index c4c2d5651..e2858a8ea 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -48,6 +48,11 @@ from lnbits.core.tasks import ( process_next_audit_entry, refresh_extension_cache, ) +from lnbits.core.wasm_ext.routes.register import register_wasm_extension +from lnbits.core.wasm_ext.wasm.events import dispatch_wasm_invoice_paid +from lnbits.core.wasm_ext.wasm.loader import ( + is_wasm_extension_id, +) from lnbits.exceptions import register_exception_handlers from lnbits.helpers import version_parse from lnbits.llms_txt import create_llms_txt_route @@ -171,6 +176,7 @@ def create_app() -> FastAPI: # Allow registering new extensions routes without direct access to the `app` object core_app_extra.register_new_ext_routes = register_new_ext_routes(app) + core_app_extra.register_new_wasm_ext_routes = register_new_wasm_ext_routes(app) core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app) # register static files @@ -417,6 +423,13 @@ def register_new_ext_routes(app: FastAPI) -> Callable: return register_new_ext_routes_fn +def register_new_wasm_ext_routes(app: FastAPI) -> Callable: + def register_new_wasm_ext_routes_fn(ext_id: str): + register_wasm_extension(app, ext_id) + + return register_new_wasm_ext_routes_fn + + def register_new_ratelimiter(app: FastAPI) -> Callable: def register_new_ratelimiter_fn(): limiter = Limiter( @@ -470,10 +483,14 @@ async def check_and_register_extensions(app: FastAPI) -> None: await check_installed_extensions(app) for ext in await get_valid_extensions(False): try: + if is_wasm_extension_id(ext.code): + register_wasm_extension(app, ext.code) + continue register_ext_routes(app, ext) register_ext_tasks(ext) except Exception as exc: logger.error(f"Could not load extension `{ext.code}`: {exc!s}") + await update_installed_extension_state(ext_id=ext.code, active=False) def register_async_tasks() -> None: @@ -511,6 +528,11 @@ def register_async_tasks() -> None: task_manager.create_permanent_task(process_next_notification) task_manager.create_permanent_task(process_next_audit_entry) + async def dispatch_extension_invoice_paid(payment) -> None: + await dispatch_wasm_invoice_paid(payment) + + task_manager.register_invoice_listener(dispatch_extension_invoice_paid, "core_wasm") + # server logs for websocket if settings.lnbits_admin_ui: server_log_task = initialize_server_websocket_logger() diff --git a/lnbits/core/crud/__init__.py b/lnbits/core/crud/__init__.py index f98c01c8c..39a8cf0d8 100644 --- a/lnbits/core/crud/__init__.py +++ b/lnbits/core/crud/__init__.py @@ -18,6 +18,7 @@ from .extensions import ( get_user_extensions, update_installed_extension, update_installed_extension_state, + update_installed_extension_wasm_runtime_limits, update_user_extension, ) from .payments import ( @@ -156,6 +157,7 @@ __all__ = [ "update_admin_settings", "update_installed_extension", "update_installed_extension_state", + "update_installed_extension_wasm_runtime_limits", "update_migration_version", "update_payment", "update_payment_checking_id", diff --git a/lnbits/core/crud/extensions.py b/lnbits/core/crud/extensions.py index 497183fc5..7348f7f3d 100644 --- a/lnbits/core/crud/extensions.py +++ b/lnbits/core/crud/extensions.py @@ -1,7 +1,12 @@ +import json +from datetime import datetime, timedelta, timezone + from lnbits.core.db import db from lnbits.core.models.extensions import ( InstallableExtension, UserExtension, + WasmInvocation, + WasmInvocationStats, ) from lnbits.db import Connection, Database @@ -11,6 +16,11 @@ async def create_installed_extension( conn: Connection | None = None, ) -> None: await (conn or db).insert("installed_extensions", ext) + await update_installed_extension_wasm_runtime_limits( + ext_id=ext.id, + limits=ext.wasm_runtime_limits, + conn=conn, + ) async def update_installed_extension( @@ -18,6 +28,11 @@ async def update_installed_extension( conn: Connection | None = None, ) -> None: await (conn or db).update("installed_extensions", ext) + await update_installed_extension_wasm_runtime_limits( + ext_id=ext.id, + limits=ext.wasm_runtime_limits, + conn=conn, + ) async def update_installed_extension_state( @@ -31,6 +46,33 @@ async def update_installed_extension_state( ) +async def update_installed_extension_wasm_runtime_limits( + *, ext_id: str, limits: dict, conn: Connection | None = None +) -> None: + if not await _has_installed_extension_wasm_runtime_limits_column(conn=conn): + return + + await (conn or db).execute( + """ + UPDATE installed_extensions + SET wasm_runtime_limits = :limits + WHERE id = :id + """, + {"id": ext_id, "limits": json.dumps(limits)}, + ) + + +async def _has_installed_extension_wasm_runtime_limits_column( + conn: Connection | None = None, +) -> bool: + row: dict | None = await (conn or db).fetchone( + "SELECT version FROM dbversions WHERE db = 'core'" + ) + if not row: + return False + return int(row["version"] or 0) >= 48 + + async def delete_installed_extension( *, ext_id: str, conn: Connection | None = None ) -> None: @@ -144,3 +186,158 @@ async def get_user_active_extensions_ids( UserExtension, ) return [ext.extension for ext in exts] + + +async def create_wasm_invocation( + invocation: WasmInvocation, + conn: Connection | None = None, +) -> None: + await (conn or db).insert("wasm_invocations", invocation) + + +async def update_wasm_invocation( + invocation: WasmInvocation, + conn: Connection | None = None, +) -> None: + await (conn or db).update("wasm_invocations", invocation) + + +async def get_wasm_invocation( + invocation_id: str, + conn: Connection | None = None, +) -> WasmInvocation | None: + return await (conn or db).fetchone( + "SELECT * FROM wasm_invocations WHERE id = :id", + {"id": invocation_id}, + model=WasmInvocation, + ) + + +async def get_wasm_invocations( + *, + extension_id: str | None = None, + status: str | None = None, + limit: int = 100, + offset: int = 0, + conn: Connection | None = None, +) -> list[WasmInvocation]: + where: list[str] = [] + values: dict = { + "limit": max(1, min(limit, 500)), + "offset": max(offset, 0), + } + if extension_id: + where.append("extension_id = :extension_id") + values["extension_id"] = extension_id + if status: + where.append("status = :status") + values["status"] = status + + query = "SELECT * FROM wasm_invocations" + if where: + query += f" WHERE {' AND '.join(where)}" + query += " ORDER BY started_at DESC LIMIT :limit OFFSET :offset" + + return await (conn or db).fetchall(query, values, model=WasmInvocation) + + +async def get_running_wasm_invocations( + conn: Connection | None = None, +) -> list[WasmInvocation]: + return await get_wasm_invocations(status="running", conn=conn) + + +async def get_wasm_invocation_stats( + *, + extension_id: str | None = None, + since: datetime | None = None, + conn: Connection | None = None, +) -> WasmInvocationStats: + database = conn or db + where: list[str] = [] + values: dict = {} + if extension_id: + where.append("extension_id = :extension_id") + values["extension_id"] = extension_id + if since: + where.append(f"started_at >= {database.timestamp_placeholder('since')}") + values["since"] = since + + query = """ + SELECT + COUNT(*) AS total, + COALESCE(SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END), 0) + AS running, + COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) + AS completed, + COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) + AS failed, + COALESCE(SUM(CASE WHEN status = 'stopped' THEN 1 ELSE 0 END), 0) + AS stopped, + COALESCE(SUM(CASE WHEN status = 'timeout' THEN 1 ELSE 0 END), 0) + AS timeout, + COALESCE(AVG(duration_ms), 0) AS avg_duration_ms, + COALESCE(MAX(duration_ms), 0) AS max_duration_ms, + COALESCE(SUM(host_call_count), 0) AS host_call_count, + COALESCE(SUM(http_call_count), 0) AS http_call_count, + COALESCE(SUM(storage_call_count), 0) AS storage_call_count, + COALESCE(SUM(wallet_call_count), 0) AS wallet_call_count + FROM wasm_invocations + """ + if where: + query += f" WHERE {' AND '.join(where)}" + + row: dict | None = await (conn or db).fetchone(query, values) + if not row: + return WasmInvocationStats() + + return WasmInvocationStats( + total=int(row["total"] or 0), + running=int(row["running"] or 0), + completed=int(row["completed"] or 0), + failed=int(row["failed"] or 0), + stopped=int(row["stopped"] or 0), + timeout=int(row["timeout"] or 0), + avg_duration_ms=float(row["avg_duration_ms"] or 0), + max_duration_ms=int(row["max_duration_ms"] or 0), + host_call_count=int(row["host_call_count"] or 0), + http_call_count=int(row["http_call_count"] or 0), + storage_call_count=int(row["storage_call_count"] or 0), + wallet_call_count=int(row["wallet_call_count"] or 0), + ) + + +async def delete_old_wasm_invocations( + retention_days: int, + conn: Connection | None = None, +) -> int: + if retention_days <= 0: + return 0 + + cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) + database = conn or db + result = await database.execute( + f""" + DELETE FROM wasm_invocations + WHERE status != 'running' + AND started_at < {database.timestamp_placeholder("cutoff")} + """, # noqa: S608 + {"cutoff": cutoff}, + ) + return int(result.rowcount or 0) + + +async def mark_stale_wasm_invocations( + conn: Connection | None = None, +) -> None: + database = conn or db + await database.execute( + f""" + UPDATE wasm_invocations + SET status = 'abandoned', + finished_at = {database.timestamp_placeholder("finished_at")}, + stop_reason = 'Server restarted before invocation finished.' + WHERE status = 'running' + """, # noqa: S608 + {"finished_at": datetime.now(timezone.utc)}, + ) diff --git a/lnbits/core/helpers.py b/lnbits/core/helpers.py index 1b1c24b1b..35b92629a 100644 --- a/lnbits/core/helpers.py +++ b/lnbits/core/helpers.py @@ -15,6 +15,8 @@ from lnbits.core.crud import ( from lnbits.core.db import db as core_db from lnbits.core.models import DbVersion from lnbits.core.models.extensions import InstallableExtension +from lnbits.core.wasm_ext.storage.crud import migrate_wasm_extension_database +from lnbits.core.wasm_ext.wasm.loader import is_wasm_extension_id from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection from lnbits.settings import settings @@ -22,7 +24,16 @@ from lnbits.settings import settings async def migrate_extension_database( ext: InstallableExtension, current_version: DbVersion | None = None ): + if is_wasm_extension_id(ext.id): + await migrate_wasm_extension_database(ext, current_version) + return + else: + await migrate_py_extension_database(ext, current_version) + +async def migrate_py_extension_database( + ext: InstallableExtension, current_version: DbVersion | None = None +): try: ext_migrations = importlib.import_module(f"{ext.module_name}.migrations") ext_db = importlib.import_module(ext.module_name).db diff --git a/lnbits/core/migrations.py b/lnbits/core/migrations.py index d39bf5932..2b084a35a 100644 --- a/lnbits/core/migrations.py +++ b/lnbits/core/migrations.py @@ -815,3 +815,78 @@ async def m045_add_external_id_to_payments(db: Connection): CREATE INDEX IF NOT EXISTS idx_payments_external_id ON apipayments (external_id); """) + + +async def m046_add_permissions_to_installed_extensions(db: Connection): + """ + Adds granted permissions to installed extensions. + """ + await db.execute( + "ALTER TABLE installed_extensions ADD COLUMN permissions TEXT DEFAULT '[]'" + ) + + +async def m047_create_wasm_invocations_table(db: Connection): + """ + Tracks WASM extension invocations for runtime monitoring and controls. + """ + await db.execute(f""" + CREATE TABLE IF NOT EXISTS wasm_invocations ( + id TEXT PRIMARY KEY, + extension_id TEXT NOT NULL, + export_name TEXT NOT NULL, + trigger_type TEXT NOT NULL DEFAULT 'unknown', + status TEXT NOT NULL DEFAULT 'running', + started_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + finished_at TIMESTAMP, + duration_ms INT, + user_id TEXT, + wallet_id TEXT, + request_id TEXT, + method TEXT, + path TEXT, + event_type TEXT, + payment_hash TEXT, + checking_id TEXT, + memory_peak_bytes INT, + request_bytes INT, + response_bytes INT, + host_call_count INT NOT NULL DEFAULT 0, + http_call_count INT NOT NULL DEFAULT 0, + storage_call_count INT NOT NULL DEFAULT 0, + wallet_call_count INT NOT NULL DEFAULT 0, + error_type TEXT, + error_message TEXT, + stop_reason TEXT, + "context" TEXT NOT NULL DEFAULT '{{}}' + ); + """) + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_wasm_invocations_extension_started + ON wasm_invocations (extension_id, started_at); + """) + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_wasm_invocations_status + ON wasm_invocations (status); + """) + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_wasm_invocations_started + ON wasm_invocations (started_at); + """) + + +async def m048_add_wasm_runtime_limits_to_installed_extensions(db: Connection): + """ + Adds per-extension WASM runtime limit overrides. + """ + await db.execute( + "ALTER TABLE installed_extensions " + "ADD COLUMN wasm_runtime_limits TEXT DEFAULT '{}'" + ) + + +async def m049_add_permissions_to_user_extensions(db: Connection): + """ + Adds user-level extension permission grants. + """ + await db.execute("ALTER TABLE extensions ADD COLUMN permissions TEXT DEFAULT '{}'") diff --git a/lnbits/core/models/extensions.py b/lnbits/core/models/extensions.py index 79e93e6fd..482bc8202 100644 --- a/lnbits/core/models/extensions.py +++ b/lnbits/core/models/extensions.py @@ -6,12 +6,16 @@ import json import os import shutil import zipfile -from pathlib import Path +from collections.abc import Mapping +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path, PurePosixPath from typing import Any +from uuid import uuid4 import httpx from loguru import logger -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, StrictStr from lnbits.helpers import ( download_url, @@ -77,6 +81,23 @@ class GitHubRepo(BaseModel): default_branch: str +class ExtensionPermission(BaseModel): + id: StrictStr + description: StrictStr | None = None + policies: list[Any] | None = None + + class Config: + extra = "ignore" + + @staticmethod + def list_from_config(config_json: Mapping[str, Any]) -> list[ExtensionPermission]: + return [ + ExtensionPermission.parse_obj(permission) + for permission in config_json.get("permissions") or [] + if isinstance(permission, dict) and permission.get("id") + ] + + class ExtensionConfig(BaseModel): name: str short_description: str @@ -84,6 +105,8 @@ class ExtensionConfig(BaseModel): warning: str | None = "" min_lnbits_version: str | None max_lnbits_version: str | None + extension_type: str | None = None + permissions: list[ExtensionPermission] = [] def is_version_compatible(self) -> bool: return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version) @@ -118,11 +141,83 @@ class UserExtensionInfo(BaseModel): payment_hash_to_enable: str | None = None +class ExtensionBackgroundPaymentDestinationPolicy(str, Enum): + OWN_WALLETS_ONLY = "own_wallets_only" + EXTERNAL_ALLOWED = "external_allowed" + + +class ExtensionBackgroundPaymentGrant(BaseModel): + id: StrictStr = Field(..., min_length=1, max_length=128) + wallet_id: str = Field(..., min_length=1, max_length=128) + enabled: bool = True + max_amount: int = Field(..., gt=0) + destination_policy: ExtensionBackgroundPaymentDestinationPolicy + + +class ExtensionBackgroundPaymentGrantRequest(BaseModel): + wallet_id: str = Field(..., min_length=1, max_length=128) + max_amount: int = Field(..., gt=0) + destination_policy: ExtensionBackgroundPaymentDestinationPolicy + + def to_grant(self, grant_id: str | None = None) -> ExtensionBackgroundPaymentGrant: + return ExtensionBackgroundPaymentGrant( + id=grant_id or str(uuid4()), + wallet_id=self.wallet_id, + enabled=True, + max_amount=self.max_amount, + destination_policy=self.destination_policy, + ) + + +class ExtensionWalletPaymentsWatchGrant(BaseModel): + id: StrictStr = Field(..., min_length=1, max_length=128) + wallet_id: str = Field(..., min_length=1, max_length=128) + enabled: bool = True + + +class ExtensionWalletPaymentsWatchGrantRequest(BaseModel): + wallet_id: str = Field(..., min_length=1, max_length=128) + + def to_grant( + self, grant_id: str | None = None + ) -> ExtensionWalletPaymentsWatchGrant: + return ExtensionWalletPaymentsWatchGrant( + id=grant_id or str(uuid4()), + wallet_id=self.wallet_id, + enabled=True, + ) + + +class ExtensionPermissionCheckItem(BaseModel): + id: StrictStr + grant: dict[str, Any] = Field(default_factory=dict) + + +class ExtensionPermissionCheckRequest(BaseModel): + permissions: list[ExtensionPermissionCheckItem] = Field(default_factory=list) + + +class ExtensionPermissionCheckResult(BaseModel): + id: StrictStr + approved: bool + grant: dict[str, Any] = Field(default_factory=dict) + + +class ExtensionPermissionCheckResponse(BaseModel): + permissions: list[ExtensionPermissionCheckResult] = Field(default_factory=list) + + +class ExtensionPermissionsResponse(BaseModel): + extension_permissions: list[ExtensionPermission] = Field(default_factory=list) + user_permissions: dict[str, list[dict[str, Any]]] = Field(default_factory=dict) + + class UserExtension(BaseModel): user: str extension: str active: bool extra: UserExtensionInfo | None = None + permissions: dict = Field(default_factory=dict) @property def is_paid(self) -> bool: @@ -144,6 +239,7 @@ class UserExtension(BaseModel): class Extension(BaseModel): code: str is_valid: bool + is_wasm: bool = False name: str | None = None short_description: str | None = None tile: str | None = None @@ -160,6 +256,8 @@ class Extension(BaseModel): @property def is_upgrade_extension(self) -> bool: + if self.is_wasm: + return False return self.upgrade_hash != "" @classmethod @@ -167,13 +265,71 @@ class Extension(BaseModel): return Extension( code=ext_info.id, is_valid=True, + is_wasm=ext_info.is_wasm, name=ext_info.name, short_description=ext_info.short_description, - tile=ext_info.icon, + tile=_extension_tile(ext_info), upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "", ) +class WasmInvocation(BaseModel): + id: str + extension_id: str + export_name: str + trigger_type: str = "unknown" + status: str = "running" + started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + finished_at: datetime | None = None + duration_ms: int | None = None + user_id: str | None = None + wallet_id: str | None = None + request_id: str | None = None + method: str | None = None + path: str | None = None + event_type: str | None = None + payment_hash: str | None = None + checking_id: str | None = None + memory_peak_bytes: int | None = None + request_bytes: int | None = None + response_bytes: int | None = None + host_call_count: int = 0 + http_call_count: int = 0 + storage_call_count: int = 0 + wallet_call_count: int = 0 + error_type: str | None = None + error_message: str | None = None + stop_reason: str | None = None + context: dict = Field(default_factory=dict) + + +class WasmInvocationStats(BaseModel): + total: int = 0 + running: int = 0 + completed: int = 0 + failed: int = 0 + stopped: int = 0 + timeout: int = 0 + avg_duration_ms: float = 0 + max_duration_ms: int = 0 + host_call_count: int = 0 + http_call_count: int = 0 + storage_call_count: int = 0 + wallet_call_count: int = 0 + + +class WasmRuntimeLimitsUpdate(BaseModel): + limits: dict[str, Any] = Field(default_factory=dict) + + +class WasmRuntimeLimitsInfo(BaseModel): + id: str + name: str + active: bool | None = False + wasm_runtime_limits: dict[str, int] = Field(default_factory=dict) + effective_wasm_runtime_limits: dict[str, int] = Field(default_factory=dict) + + class ExtensionRelease(BaseModel): name: str version: str @@ -348,6 +504,8 @@ class InstallableExtension(BaseModel): icon: str | None = None stars: int = 0 meta: ExtensionMeta | None = None + permissions: list[ExtensionPermission] = [] + wasm_runtime_limits: dict = Field(default_factory=dict, no_database=True) @property def hash(self) -> str: @@ -400,6 +558,18 @@ class InstallableExtension(BaseModel): return False return self.meta.pay_to_enable.required is True + @property + def is_wasm(self) -> bool: + config_path = Path(self.ext_dir, "config.json") + if not config_path.is_file(): + return False + try: + with open(config_path, encoding="utf-8") as json_file: + config_json = json.load(json_file) + except Exception: + return False + return config_json.get("extension_type") == "wasm" + async def download_archive(self): logger.info(f"Downloading extension {self.name} ({self.installed_version}).") ext_zip_file = self.zip_path @@ -432,6 +602,22 @@ class InstallableExtension(BaseModel): os.remove(ext_zip_file) raise AssertionError("File hash missmatch. Will not install.") + def load_archive_config(self) -> dict[str, Any]: + if not self.zip_path.is_file(): + return {} + + try: + with zipfile.ZipFile(self.zip_path, "r") as archive: + config_name = _archive_config_name(archive.namelist()) + if not config_name: + return {} + with archive.open(config_name) as config_file: + config = json.load(config_file) + except Exception as exc: + raise ValueError(f"Cannot read extension config for '{self.id}'.") from exc + + return config if isinstance(config, dict) else {} + def extract_archive(self): logger.info(f"Extracting extension {self.name} ({self.installed_version}).") Path(settings.lnbits_extensions_upgrade_path).mkdir(parents=True, exist_ok=True) @@ -610,6 +796,7 @@ class InstallableExtension(BaseModel): version=version, short_description=config_json.get("short_description"), icon=config_json.get("tile"), + permissions=ExtensionPermission.list_from_config(config_json), meta=ExtensionMeta( installed_release=ExtensionRelease( name=ext_id, @@ -803,6 +990,7 @@ class CreateExtension(BaseModel): version: str cost_sats: int | None = 0 payment_hash: str | None = None + permissions: list[ExtensionPermission] = [] class ExtensionDetailsRequest(BaseModel): @@ -855,3 +1043,21 @@ def icon_to_github_url(source_repo: str, path: str | None) -> str: _, _, *rest = path.split("/") tail = "/".join(rest) return f"https://github.com/{source_repo}/raw/main/{tail}" + + +def wasm_extension_icon_url(ext_id: str) -> str: + return f"/ext-assets/{ext_id}/assets/icon.png" + + +def _extension_tile(ext_info: InstallableExtension) -> str | None: + if ext_info.is_wasm: + return wasm_extension_icon_url(ext_info.id) + return ext_info.icon + + +def _archive_config_name(names: list[str]) -> str | None: + for name in names: + path = PurePosixPath(name) + if len(path.parts) == 2 and path.name == "config.json": + return name + return None diff --git a/lnbits/core/models/misc.py b/lnbits/core/models/misc.py index 5aad3fdc9..9306ad03f 100644 --- a/lnbits/core/models/misc.py +++ b/lnbits/core/models/misc.py @@ -1,6 +1,8 @@ from __future__ import annotations from collections.abc import Callable +from pathlib import Path +from typing import Any from pydantic import BaseModel @@ -11,8 +13,41 @@ def _do_nothing(*_): class CoreAppExtra: register_new_ext_routes: Callable = _do_nothing + register_new_wasm_ext_routes: Callable = _do_nothing register_new_ratelimiter: Callable + def __init__(self) -> None: + self.wasm_extension_registry = WasmExtensionRegistry() + + +class WasmExtensionRegistry: + def __init__(self) -> None: + self._extensions: dict[str, Any] = {} + + def register(self, extension: Any) -> None: + self.require_available(extension) + self._extensions[extension.id] = extension + + def require_available(self, extension: Any) -> None: + existing = self._extensions.get(extension.id) + if existing and not _same_wasm_extension_registration(existing, extension): + raise ValueError( + f"WASM extension id '{extension.id}' is already registered." + ) + + def get(self, ext_id: str) -> Any | None: + return self._extensions.get(ext_id) + + def list(self) -> list[Any]: + return list(self._extensions.values()) + + +def _same_wasm_extension_registration(left: Any, right: Any) -> bool: + try: + return Path(left.root_path).resolve() == Path(right.root_path).resolve() + except (AttributeError, TypeError): + return left is right + class ConversionData(BaseModel): from_: str = "sat" diff --git a/lnbits/core/services/extensions.py b/lnbits/core/services/extensions.py index 411ed6cb5..b09f9bdb3 100644 --- a/lnbits/core/services/extensions.py +++ b/lnbits/core/services/extensions.py @@ -1,5 +1,12 @@ import asyncio import importlib +import re +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from threading import RLock +from typing import Any +from uuid import uuid4 from loguru import logger @@ -13,18 +20,173 @@ from lnbits.core.crud import ( update_installed_extension_state, ) from lnbits.core.crud.extensions import ( + create_wasm_invocation, + delete_old_wasm_invocations, get_installed_extensions, + get_wasm_invocation, + mark_stale_wasm_invocations, update_installed_extension, + update_installed_extension_wasm_runtime_limits, + update_wasm_invocation, +) +from lnbits.core.crud.extensions import ( + get_wasm_invocation_stats as get_wasm_invocation_stats_crud, +) +from lnbits.core.crud.extensions import ( + get_wasm_invocations as get_wasm_invocations_crud, ) from lnbits.core.helpers import migrate_extension_database +from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions +from lnbits.core.wasm_ext.wasm.loader import is_wasm_extension_id from lnbits.db import Connection -from lnbits.settings import settings +from lnbits.settings import WasmRuntimeLimits, settings -from ..models.extensions import Extension, ExtensionMeta, InstallableExtension +from ..models.extensions import ( + Extension, + ExtensionMeta, + ExtensionPermission, + InstallableExtension, + WasmInvocation, + WasmInvocationStats, +) + +_WASM_INVOCATION_CLEANUP_INTERVAL = timedelta(hours=1) +WASM_RUNTIME_LIMIT_FIELDS = tuple(WasmRuntimeLimits.__fields__.keys()) + + +@dataclass +class WasmInvocationHandle: + invocation: WasmInvocation + engine: Any | None = None + store: Any | None = None + runtime_limits: dict[str, int] | None = None + stop_requested: bool = False + stop_reason: str | None = None + + +_wasm_invocation_lock = RLock() +_wasm_invocation_ready_lock = asyncio.Lock() +_wasm_invocation_handles: dict[str, WasmInvocationHandle] = {} +_wasm_invocations_marked_stale = False +_wasm_invocations_last_cleanup_at: datetime | None = None + + +def wasm_runtime_limit_defaults() -> dict[str, int]: + return {field: int(getattr(settings, field)) for field in WASM_RUNTIME_LIMIT_FIELDS} + + +def validate_wasm_runtime_limit_overrides( + limits: Mapping[str, Any] | None, + *, + strict: bool = True, +) -> dict[str, int]: + if not limits: + return {} + + validated: dict[str, int] = {} + for field, raw_value in limits.items(): + if field not in WASM_RUNTIME_LIMIT_FIELDS: + if strict: + raise ValueError(f"Unknown WASM runtime limit field '{field}'.") + continue + + value = _validate_wasm_runtime_limit_value(field, raw_value, strict=strict) + if value is None: + continue + validated[field] = value + + return validated + + +def _validate_wasm_runtime_limit_value( + field: str, + raw_value: Any, + *, + strict: bool, +) -> int | None: + if raw_value is None or raw_value == "": + return None + if isinstance(raw_value, bool): + return _invalid_wasm_runtime_limit(field, strict, "must be an integer") + if isinstance(raw_value, str): + raw_value = raw_value.strip() + if raw_value == "": + return None + if not raw_value.isdecimal(): + return _invalid_wasm_runtime_limit(field, strict, "must be an integer") + if isinstance(raw_value, float) and not raw_value.is_integer(): + return _invalid_wasm_runtime_limit(field, strict, "must be an integer") + + try: + value = int(raw_value) + except (TypeError, ValueError) as exc: + return _invalid_wasm_runtime_limit( + field, + strict, + "must be an integer", + exc=exc, + ) + if value < 0: + return _invalid_wasm_runtime_limit(field, strict, "cannot be negative") + return value + + +def _invalid_wasm_runtime_limit( + field: str, + strict: bool, + message: str, + *, + exc: Exception | None = None, +) -> int | None: + if not strict: + return None + error = ValueError(f"WASM runtime limit '{field}' {message}.") + if exc: + raise error from exc + raise error + + +def resolve_wasm_runtime_limits( + installed_extension: InstallableExtension | None = None, +) -> dict[str, int]: + limits = wasm_runtime_limit_defaults() + if installed_extension: + limits.update( + validate_wasm_runtime_limit_overrides( + installed_extension.wasm_runtime_limits, + strict=False, + ) + ) + return limits + + +async def get_wasm_runtime_limits_for_extension(ext_id: str) -> dict[str, int]: + installed_extension = await get_installed_extension(ext_id) + return resolve_wasm_runtime_limits(installed_extension) + + +async def update_wasm_extension_runtime_limits( + ext_id: str, + limits: Mapping[str, Any] | None, +) -> dict[str, int]: + installed_extension = await get_installed_extension(ext_id) + if not installed_extension: + raise ValueError(f"Extension '{ext_id}' is not installed.") + if not installed_extension.is_wasm: + raise ValueError(f"Extension '{ext_id}' is not a WASM extension.") + + validated_limits = validate_wasm_runtime_limit_overrides(limits) + await update_installed_extension_wasm_runtime_limits( + ext_id=ext_id, + limits=validated_limits, + ) + return validated_limits async def install_extension( - ext_info: InstallableExtension, skip_download: bool | None = False + ext_info: InstallableExtension, + skip_download: bool | None = False, + granted_permissions: list[ExtensionPermission] | None = None, ) -> Extension: ext_info.meta = ext_info.meta or ExtensionMeta() @@ -38,12 +200,19 @@ async def install_extension( installed_ext = await get_installed_extension(ext_info.id) if installed_ext and installed_ext.meta: ext_info.meta.payments = installed_ext.meta.payments + if installed_ext: + ext_info.wasm_runtime_limits = installed_ext.wasm_runtime_limits await check_extensions_limit(installed_ext) if not skip_download: await ext_info.download_archive() + extension_config = ext_info.load_archive_config() + ext_info.permissions = validate_wasm_extension_permissions( + ext_info, granted_permissions, extension_config + ) + ext_info.extract_archive() db_version = await get_db_version(ext_info.id) @@ -61,7 +230,8 @@ async def install_extension( # call stop while the old routes are still active await stop_extension_background_work(ext_info.id) - await start_extension_background_work(ext_info.id) + if not extension.is_wasm: + await start_extension_background_work(ext_info.id) return extension @@ -75,6 +245,394 @@ async def check_extensions_limit(installed_ext: InstallableExtension | None = No raise ValueError("Max amount of extensions have been installed") +async def ensure_wasm_invocation_monitoring_ready() -> None: + global _wasm_invocations_last_cleanup_at, _wasm_invocations_marked_stale + + async with _wasm_invocation_ready_lock: + now = _now() + if not _wasm_invocations_marked_stale: + await mark_stale_wasm_invocations() + _wasm_invocations_marked_stale = True + + if ( + _wasm_invocations_last_cleanup_at is None + or now - _wasm_invocations_last_cleanup_at + >= _WASM_INVOCATION_CLEANUP_INTERVAL + ): + _wasm_invocations_last_cleanup_at = now + await delete_old_wasm_invocations( + settings.lnbits_wasm_invocation_retention_days + ) + + +async def start_wasm_invocation( + *, + extension_id: str, + export_name: str, + trigger_type: str = "unknown", + user_id: str | None = None, + wallet_id: str | None = None, + request_id: str | None = None, + method: str | None = None, + path: str | None = None, + event_type: str | None = None, + payment_hash: str | None = None, + checking_id: str | None = None, + request_bytes: int | None = None, + context: dict | None = None, + runtime_limits: dict[str, int] | None = None, +) -> WasmInvocation: + await ensure_wasm_invocation_monitoring_ready() + _check_wasm_invocation_concurrency( + extension_id=extension_id, + user_id=user_id, + limits=runtime_limits, + ) + + invocation = WasmInvocation( + id=uuid4().hex, + extension_id=extension_id, + export_name=export_name, + trigger_type=trigger_type, + user_id=user_id, + wallet_id=wallet_id, + request_id=request_id, + method=method, + path=path, + event_type=event_type, + payment_hash=payment_hash, + checking_id=checking_id, + request_bytes=request_bytes, + context=_safe_wasm_invocation_context(context or {}), + ) + await create_wasm_invocation(invocation) + + with _wasm_invocation_lock: + _wasm_invocation_handles[invocation.id] = WasmInvocationHandle( + invocation, + runtime_limits=runtime_limits, + ) + + return invocation + + +def attach_wasm_invocation_runtime( + invocation_id: str, + *, + engine: Any, + store: Any, +) -> None: + with _wasm_invocation_lock: + handle = _wasm_invocation_handles.get(invocation_id) + if not handle: + return + handle.engine = engine + handle.store = store + if handle.stop_requested: + _interrupt_wasm_invocation(handle) + + +def record_wasm_invocation_host_call( + invocation_id: str | None, + method_id: str, +) -> None: + if not invocation_id: + return + + with _wasm_invocation_lock: + handle = _wasm_invocation_handles.get(invocation_id) + if not handle: + return + + invocation = handle.invocation + invocation.host_call_count += 1 + category = _wasm_host_call_category(method_id) + if category == "http": + invocation.http_call_count += 1 + elif category == "storage": + invocation.storage_call_count += 1 + elif category == "wallet": + invocation.wallet_call_count += 1 + + _check_wasm_host_call_limit(invocation, category, handle.runtime_limits) + + +async def stop_wasm_invocation( + invocation_id: str, + *, + reason: str = "Stopped by admin.", +) -> bool: + interrupted = False + with _wasm_invocation_lock: + handle = _wasm_invocation_handles.get(invocation_id) + if handle: + handle.stop_requested = True + handle.stop_reason = reason + handle.invocation.stop_reason = reason + interrupted = _interrupt_wasm_invocation(handle) + + invocation = await get_wasm_invocation(invocation_id) + if invocation and invocation.status == "running": + invocation.stop_reason = reason + await update_wasm_invocation(invocation) + + return interrupted + + +async def stop_wasm_extension_invocations( + extension_id: str, + *, + reason: str = "Extension deactivated.", +) -> int: + with _wasm_invocation_lock: + invocation_ids = [ + invocation_id + for invocation_id, handle in _wasm_invocation_handles.items() + if handle.invocation.extension_id == extension_id + ] + + for invocation_id in invocation_ids: + await stop_wasm_invocation(invocation_id, reason=reason) + + return len(invocation_ids) + + +def wasm_invocation_stop_requested(invocation_id: str) -> bool: + with _wasm_invocation_lock: + handle = _wasm_invocation_handles.get(invocation_id) + return bool(handle and handle.stop_requested) + + +def get_wasm_invocation_stop_reason(invocation_id: str) -> str | None: + with _wasm_invocation_lock: + handle = _wasm_invocation_handles.get(invocation_id) + return handle.stop_reason if handle else None + + +async def finish_wasm_invocation( + invocation_id: str, + *, + status: str, + response_bytes: int | None = None, + memory_peak_bytes: int | None = None, + error_type: str | None = None, + error_message: str | None = None, + stop_reason: str | None = None, +) -> None: + with _wasm_invocation_lock: + handle = _wasm_invocation_handles.pop(invocation_id, None) + + invocation = ( + handle.invocation if handle else await get_wasm_invocation(invocation_id) + ) + if not invocation: + return + + reason = stop_reason or (handle.stop_reason if handle else None) + if handle and handle.stop_requested and status == "failed": + status = "stopped" + reason = reason or "Stopped by admin." + + finished_at = _now() + invocation.status = status + invocation.finished_at = finished_at + invocation.duration_ms = max( + 0, int((finished_at - invocation.started_at).total_seconds() * 1000) + ) + invocation.response_bytes = response_bytes + invocation.memory_peak_bytes = memory_peak_bytes + invocation.error_type = error_type + invocation.error_message = _safe_wasm_error_message(error_message) + invocation.stop_reason = reason + + await update_wasm_invocation(invocation) + + +def get_current_wasm_invocations( + extension_id: str | None = None, +) -> list[WasmInvocation]: + with _wasm_invocation_lock: + invocations = [] + for handle in _wasm_invocation_handles.values(): + if extension_id and handle.invocation.extension_id != extension_id: + continue + invocation = handle.invocation.copy(deep=True) + if handle.stop_requested and invocation.status == "running": + invocation.status = "stopping" + invocation.stop_reason = handle.stop_reason + invocations.append(invocation) + + return sorted( + invocations, key=lambda invocation: invocation.started_at, reverse=True + ) + + +def _check_wasm_invocation_concurrency( + *, + extension_id: str, + user_id: str | None, + limits: dict[str, int] | None, +) -> None: + if not limits: + return + + with _wasm_invocation_lock: + handles = list(_wasm_invocation_handles.values()) + if _wasm_limit_exceeded( + limits["wasm_runtime_max_concurrent_invocations"], + len(handles) + 1, + ): + raise ValueError("WASM runtime has too many active invocations.") + + extension_invocations = sum( + 1 for handle in handles if handle.invocation.extension_id == extension_id + ) + if _wasm_limit_exceeded( + limits["wasm_runtime_max_concurrent_invocations_per_extension"], + extension_invocations + 1, + ): + raise ValueError( + f"WASM extension '{extension_id}' has too many active invocations." + ) + + if not user_id: + return + + user_invocations = sum( + 1 for handle in handles if handle.invocation.user_id == user_id + ) + if _wasm_limit_exceeded( + limits["wasm_runtime_max_concurrent_invocations_per_user"], + user_invocations + 1, + ): + raise ValueError("WASM user has too many active invocations.") + + +def _check_wasm_host_call_limit( + invocation: WasmInvocation, + category: str, + limits: dict[str, int] | None, +) -> None: + if not limits: + return + + if _wasm_limit_exceeded( + limits["wasm_runtime_max_host_calls"], + invocation.host_call_count, + ): + raise ValueError("WASM host call limit exceeded.") + + category_limits = { + "http": ( + limits["wasm_runtime_max_http_calls"], + invocation.http_call_count, + ), + "storage": ( + limits["wasm_runtime_max_storage_calls"], + invocation.storage_call_count, + ), + "wallet": ( + limits["wasm_runtime_max_wallet_calls"], + invocation.wallet_call_count, + ), + } + category_limit = category_limits.get(category) + if category_limit and _wasm_limit_exceeded(*category_limit): + raise ValueError(f"WASM {category} host call limit exceeded.") + + +def _wasm_limit_exceeded(limit: int, value: int) -> bool: + return limit > 0 and value > limit + + +async def get_wasm_invocation_history( + *, + extension_id: str | None = None, + status: str | None = None, + limit: int = 100, + offset: int = 0, +) -> list[WasmInvocation]: + await ensure_wasm_invocation_monitoring_ready() + return await get_wasm_invocations_crud( + extension_id=extension_id, + status=status, + limit=limit, + offset=offset, + ) + + +async def get_wasm_invocation_summary( + *, + extension_id: str | None = None, + hours: int = 24, +) -> WasmInvocationStats: + await ensure_wasm_invocation_monitoring_ready() + since = _now() - timedelta(hours=max(1, min(hours, 24 * 30))) + return await get_wasm_invocation_stats_crud( + extension_id=extension_id, + since=since, + ) + + +def _interrupt_wasm_invocation(handle: WasmInvocationHandle) -> bool: + if not handle.store or not handle.engine: + return False + try: + handle.store.set_epoch_deadline(1) + handle.engine.increment_epoch() + return True + except Exception as exc: + logger.warning( + f"Failed to interrupt WASM invocation '{handle.invocation.id}': {exc}" + ) + return False + + +def _wasm_host_call_category(method_id: str) -> str: + if method_id.startswith("http.") or method_id.startswith("extension.api."): + return "http" + if method_id.startswith("storage."): + return "storage" + if method_id.startswith("wallet."): + return "wallet" + return "host" + + +def _safe_wasm_invocation_context(context: dict) -> dict: + safe_context: dict = {} + for key, value in context.items(): + if not isinstance(key, str): + continue + if value is None or isinstance(value, (bool, int, float)): + safe_context[key[:64]] = value + elif isinstance(value, str): + safe_context[key[:64]] = value[:256] + return safe_context + + +def _safe_wasm_error_message(message: str | None) -> str | None: + if not message: + return None + + safe_message = message[:500] + redactions = [ + ( + r"(?i)(api[-_ ]?key|token|authorization|password|secret|preimage)" + r"\s*[:=]\s*[^\s,;]+", + r"\1=[redacted]", + ), + (r"(?i)bearer\s+[A-Za-z0-9._~+/=-]+", "Bearer [redacted]"), + (r"\b[a-fA-F0-9]{64}\b", "[redacted-hex]"), + ] + for pattern, replacement in redactions: + safe_message = re.sub(pattern, replacement, safe_message) + return safe_message + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + async def uninstall_extension(ext_id: str): await stop_extension_background_work(ext_id) @@ -87,12 +645,19 @@ async def uninstall_extension(ext_id: str): async def activate_extension(ext: Extension): + if ext.is_wasm: + core_app_extra.register_new_wasm_ext_routes(ext.code) + await update_installed_extension_state(ext_id=ext.code, active=True) + return + core_app_extra.register_new_ext_routes(ext) await update_installed_extension_state(ext_id=ext.code, active=True) await start_extension_background_work(ext.code) async def deactivate_extension(ext_id: str): + if is_wasm_extension_id(ext_id): + await stop_wasm_extension_invocations(ext_id, reason="Extension deactivated.") settings.deactivate_extension_paths(ext_id) await update_installed_extension_state(ext_id=ext_id, active=False) await stop_extension_background_work(ext_id) @@ -103,6 +668,9 @@ async def stop_extension_background_work(ext_id: str) -> bool: Stop background work for extension (like asyncio.Tasks, WebSockets, etc). Extension must expose a `myextension_stop()` function if it is starting tasks. """ + if is_wasm_extension_id(ext_id): + return True + upgrade_hash = settings.extension_upgrade_hash(ext_id) ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash) @@ -135,6 +703,9 @@ async def start_extension_background_work(ext_id: str) -> bool: Extension CAN expose a `myextension_start()` function if it is starting tasks. Extension MUST expose a `myextension_stop()` in that case. """ + if is_wasm_extension_id(ext_id): + return False + upgrade_hash = settings.extension_upgrade_hash(ext_id) ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash) diff --git a/lnbits/core/views/extension_api.py b/lnbits/core/views/extension_api.py index bd53bd2c4..4a4dead2e 100644 --- a/lnbits/core/views/extension_api.py +++ b/lnbits/core/views/extension_api.py @@ -1,3 +1,4 @@ +import json import sys import traceback from http import HTTPStatus @@ -9,7 +10,7 @@ from fastapi.requests import Request from loguru import logger from lnbits.core.crud.extensions import get_user_extensions -from lnbits.core.crud.wallets import get_wallets_ids +from lnbits.core.crud.wallets import get_wallet, get_wallets_ids from lnbits.core.db import db from lnbits.core.models import ( SimpleStatus, @@ -18,28 +19,50 @@ from lnbits.core.models.extensions import ( CreateExtension, CreateExtensionReview, Extension, + ExtensionBackgroundPaymentDestinationPolicy, + ExtensionBackgroundPaymentGrant, + ExtensionBackgroundPaymentGrantRequest, ExtensionConfig, ExtensionMeta, + ExtensionPermissionCheckRequest, + ExtensionPermissionCheckResponse, + ExtensionPermissionCheckResult, + ExtensionPermissionsResponse, ExtensionRelease, ExtensionReview, ExtensionReviewPaymentRequest, ExtensionReviewsStatus, + ExtensionWalletPaymentsWatchGrant, + ExtensionWalletPaymentsWatchGrantRequest, InstallableExtension, PayToEnableInfo, ReleasePaymentInfo, UserExtension, UserExtensionInfo, + WasmInvocation, + WasmInvocationStats, + WasmRuntimeLimitsInfo, + WasmRuntimeLimitsUpdate, + wasm_extension_icon_url, ) from lnbits.core.models.users import Account, AccountId from lnbits.core.services import check_transaction_status, create_invoice from lnbits.core.services.extensions import ( activate_extension, deactivate_extension, + get_current_wasm_invocations, get_valid_extension, get_valid_extensions, + get_wasm_invocation_history, + get_wasm_invocation_summary, install_extension, + resolve_wasm_runtime_limits, + stop_wasm_invocation, uninstall_extension, + update_wasm_extension_runtime_limits, + validate_wasm_runtime_limit_overrides, ) +from lnbits.core.wasm_ext.api.permissions import validate_extension_permissions from lnbits.db import Page from lnbits.decorators import ( check_account_exists, @@ -66,6 +89,9 @@ extension_router = APIRouter( prefix="/api/v1/extension", ) +WALLET_PAY_INVOICE_BACKGROUND_PERMISSION = "wallet.pay_invoice_background" +WALLET_PAYMENTS_WATCH_PERMISSION = "wallet.payments.watch" + @extension_router.post("", dependencies=[Depends(check_admin)]) async def api_install_extension(data: CreateExtension): @@ -89,7 +115,9 @@ async def api_install_extension(data: CreateExtension): ) try: - extension = await install_extension(ext_info) + extension = await install_extension( + ext_info, granted_permissions=data.permissions + ) except Exception as exc: logger.warning(exc) @@ -128,6 +156,106 @@ async def api_install_extension(data: CreateExtension): ) from exc +@extension_router.get( + "/wasm/invocations/current", + dependencies=[Depends(check_admin)], +) +async def api_get_current_wasm_invocations( + extension_id: str | None = None, +) -> list[WasmInvocation]: + return get_current_wasm_invocations(extension_id=extension_id) + + +@extension_router.get( + "/wasm/invocations", + dependencies=[Depends(check_admin)], +) +async def api_get_wasm_invocations( + extension_id: str | None = None, + status: str | None = None, + limit: int = 100, + offset: int = 0, +) -> list[WasmInvocation]: + return await get_wasm_invocation_history( + extension_id=extension_id, + status=status, + limit=limit, + offset=offset, + ) + + +@extension_router.get( + "/wasm/invocations/stats", + dependencies=[Depends(check_admin)], +) +async def api_get_wasm_invocation_stats( + extension_id: str | None = None, + hours: int = 24, +) -> WasmInvocationStats: + return await get_wasm_invocation_summary(extension_id=extension_id, hours=hours) + + +@extension_router.post( + "/wasm/invocations/{invocation_id}/stop", + dependencies=[Depends(check_admin)], +) +async def api_stop_wasm_invocation(invocation_id: str) -> SimpleStatus: + await stop_wasm_invocation(invocation_id, reason="Stopped by admin.") + return SimpleStatus(success=True, message="WASM invocation stop requested.") + + +@extension_router.get( + "/wasm/runtime-limits/extensions", + dependencies=[Depends(check_admin)], +) +async def api_get_wasm_runtime_limit_extensions() -> list[WasmRuntimeLimitsInfo]: + installed_extensions = await get_installed_extensions() + return [ + WasmRuntimeLimitsInfo( + id=extension.id, + name=extension.name, + active=extension.active, + wasm_runtime_limits=validate_wasm_runtime_limit_overrides( + extension.wasm_runtime_limits, + strict=False, + ), + effective_wasm_runtime_limits=resolve_wasm_runtime_limits(extension), + ) + for extension in installed_extensions + if extension.is_wasm + ] + + +@extension_router.put( + "/wasm/runtime-limits/{ext_id}", + dependencies=[Depends(check_admin)], +) +async def api_update_wasm_runtime_limits( + ext_id: str, + data: WasmRuntimeLimitsUpdate, +) -> WasmRuntimeLimitsInfo: + try: + wasm_runtime_limits = await update_wasm_extension_runtime_limits( + ext_id, data.limits + ) + extension = await get_installed_extension(ext_id) + if not extension: + raise ValueError(f"Extension '{ext_id}' is not installed.") + extension.wasm_runtime_limits = wasm_runtime_limits + return WasmRuntimeLimitsInfo( + id=extension.id, + name=extension.name, + active=extension.active, + wasm_runtime_limits=wasm_runtime_limits, + effective_wasm_runtime_limits=resolve_wasm_runtime_limits(extension), + ) + except ValueError as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(exc), + ) from exc + + @extension_router.get("/{ext_id}/details") async def api_extension_details( ext_id: str, @@ -251,6 +379,213 @@ async def api_disable_extension( return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.") +@extension_router.post("/{ext_id}/permissions/background-payment") +async def api_grant_background_payment_permission( + ext_id: str, + data: ExtensionBackgroundPaymentGrantRequest, + account_id: AccountId = Depends(check_account_id_exists), +) -> dict: + installed_ext = await get_installed_extension(ext_id) + if not installed_ext or not installed_ext.active: + raise HTTPException( + HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active." + ) + + installed_permission_ids = { + permission.id for permission in installed_ext.permissions or [] + } + if WALLET_PAY_INVOICE_BACKGROUND_PERMISSION not in installed_permission_ids: + raise HTTPException( + HTTPStatus.FORBIDDEN, + f"Extension '{ext_id}' cannot request background payments.", + ) + + user_ext = await get_user_extension(account_id.id, ext_id) + if not user_ext or not user_ext.active: + raise HTTPException( + HTTPStatus.FORBIDDEN, + f"Extension '{ext_id}' is not enabled for this user.", + ) + + wallet = await get_wallet(data.wallet_id) + if not wallet or wallet.user != account_id.id: + raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.") + if wallet.is_lightning_shared_wallet: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "Background payments are not allowed from shared wallets.", + ) + if not wallet.can_send_payments: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "This wallet cannot send payments.", + ) + + permissions = user_ext.permissions or {} + grant = data.to_grant( + _user_permission_grant_id_for_wallet( + permissions, + WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, + data.wallet_id, + ) + ) + background_grants = [ + existing + for existing in permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, []) + if isinstance(existing, dict) and existing.get("wallet_id") != grant.wallet_id + ] + background_grants.append(json.loads(grant.json())) + permissions[WALLET_PAY_INVOICE_BACKGROUND_PERMISSION] = background_grants + user_ext.permissions = permissions + await update_user_extension(user_ext) + return {"permission": WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, "grant": grant} + + +@extension_router.post("/{ext_id}/permissions/wallet-payments-watch") +async def api_grant_wallet_payments_watch_permission( + ext_id: str, + data: ExtensionWalletPaymentsWatchGrantRequest, + account_id: AccountId = Depends(check_account_id_exists), +) -> dict: + installed_ext = await get_installed_extension(ext_id) + if not installed_ext or not installed_ext.active: + raise HTTPException( + HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active." + ) + + installed_permission_ids = { + permission.id for permission in installed_ext.permissions or [] + } + if WALLET_PAYMENTS_WATCH_PERMISSION not in installed_permission_ids: + raise HTTPException( + HTTPStatus.FORBIDDEN, + f"Extension '{ext_id}' cannot request wallet payment watch access.", + ) + + user_ext = await get_user_extension(account_id.id, ext_id) + if not user_ext or not user_ext.active: + raise HTTPException( + HTTPStatus.FORBIDDEN, + f"Extension '{ext_id}' is not enabled for this user.", + ) + + wallet = await get_wallet(data.wallet_id) + if not wallet or wallet.user != account_id.id: + raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.") + + permissions = user_ext.permissions or {} + grant = data.to_grant( + _user_permission_grant_id_for_wallet( + permissions, + WALLET_PAYMENTS_WATCH_PERMISSION, + data.wallet_id, + ) + ) + watch_grants = [ + existing + for existing in permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION, []) + if isinstance(existing, dict) and existing.get("wallet_id") != grant.wallet_id + ] + watch_grants.append(json.loads(grant.json())) + permissions[WALLET_PAYMENTS_WATCH_PERMISSION] = watch_grants + user_ext.permissions = permissions + await update_user_extension(user_ext) + return {"permission": WALLET_PAYMENTS_WATCH_PERMISSION, "grant": grant} + + +@extension_router.get("/{ext_id}/permissions") +async def api_get_extension_permissions( + ext_id: str, + account_id: AccountId = Depends(check_account_id_exists), +) -> ExtensionPermissionsResponse: + installed_ext = await _require_active_wasm_extension(ext_id) + extension_permissions = validate_extension_permissions( + installed_ext.id, installed_ext.permissions, strict=False + ) + user_ext = await get_user_extension(account_id.id, ext_id) + return ExtensionPermissionsResponse( + extension_permissions=extension_permissions, + user_permissions=_safe_user_extension_permissions( + user_ext.permissions if user_ext else {} + ), + ) + + +@extension_router.delete("/{ext_id}/permissions/user/{grant_id}") +async def api_delete_user_extension_permission( + ext_id: str, + grant_id: str, + account_id: AccountId = Depends(check_account_id_exists), +) -> SimpleStatus: + await _require_active_wasm_extension(ext_id) + + user_ext = await get_user_extension(account_id.id, ext_id) + if not user_ext: + return SimpleStatus(success=True, message="Permission grant removed.") + + user_ext.permissions = _remove_user_permission_grant( + user_ext.permissions or {}, grant_id + ) + await update_user_extension(user_ext) + return SimpleStatus(success=True, message="Permission grant removed.") + + +@extension_router.post("/{ext_id}/permissions/check") +async def api_check_extension_permissions( + ext_id: str, + data: ExtensionPermissionCheckRequest, + account_id: AccountId = Depends(check_account_id_exists), +) -> ExtensionPermissionCheckResponse: + installed_ext = await get_installed_extension(ext_id) + if not installed_ext or not installed_ext.active: + raise HTTPException( + HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active." + ) + + installed_permission_ids = { + permission.id for permission in installed_ext.permissions or [] + } + + user_ext = await get_user_extension(account_id.id, ext_id) + if not user_ext or not user_ext.active: + raise HTTPException( + HTTPStatus.FORBIDDEN, + f"Extension '{ext_id}' is not enabled for this user.", + ) + + results: list[ExtensionPermissionCheckResult] = [] + for permission in data.permissions: + if permission.id not in installed_permission_ids: + raise HTTPException( + HTTPStatus.FORBIDDEN, + f"Extension '{ext_id}' cannot request '{permission.id}'.", + ) + if permission.id == WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: + results.append( + await _check_background_payment_permission( + account_id.id, + user_ext.permissions or {}, + permission.grant, + ) + ) + continue + if permission.id == WALLET_PAYMENTS_WATCH_PERMISSION: + results.append( + await _check_wallet_payments_watch_permission( + account_id.id, + user_ext.permissions or {}, + permission.grant, + ) + ) + continue + raise HTTPException( + HTTPStatus.BAD_REQUEST, + f"Unsupported permission check '{permission.id}'.", + ) + + return ExtensionPermissionCheckResponse(permissions=results) + + @extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)]) async def api_activate_extension(ext_id: str) -> SimpleStatus: try: @@ -459,11 +794,19 @@ async def get_extension_release(org: str, repo: str, tag_name: str): if not config: return {} + permissions = validate_extension_permissions(config.name, config.permissions) + return { "min_lnbits_version": config.min_lnbits_version, "is_version_compatible": config.is_version_compatible(), "warning": config.warning, + "extension_type": config.extension_type, + "permissions": [dict(permission) for permission in permissions], } + except ValueError as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) + ) from exc except Exception as exc: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc) @@ -535,9 +878,10 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)): ) installable_exts_ids = [e.id for e in installable_exts] installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids] + installed_exts_by_id = {e.id: e for e in installed_exts} for e in installable_exts: - installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None) + installed_ext = installed_exts_by_id.get(e.id) if installed_ext and installed_ext.meta: installed_release = installed_ext.meta.installed_release if installed_ext.meta.pay_to_enable and not account_id.is_admin_id: @@ -558,47 +902,60 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)): e.short_description = installed_ext.short_description e.icon = installed_ext.icon - extension_data = [ - { - "id": ext.id, - "name": ext.name, - "icon": ext.icon, - "shortDescription": ext.short_description, - "stars": ext.stars, - "isFeatured": ext.meta.featured if ext.meta else False, - "categories": ext.meta.categories if ext.meta else [], - "dependencies": ext.meta.dependencies if ext.meta else "", - "isInstalled": ext.id in installed_exts_ids, - "hasDatabaseTables": next( - (True for version in db_versions if version.db == ext.id), False - ), - "isAvailable": ext.id in all_ext_ids, - "isAdminOnly": ext.id in settings.lnbits_admin_extensions, - "isActive": ext.id not in inactive_extensions, - "latestRelease": ( - dict(ext.meta.latest_release) - if ext.meta and ext.meta.latest_release - else None - ), - "hasPaidRelease": ext.meta.has_paid_release if ext.meta else False, - "hasFreeRelease": ext.meta.has_free_release if ext.meta else False, - "paidFeatures": ext.meta.paid_features if ext.meta else False, - "installedRelease": ( - dict(ext.meta.installed_release) - if ext.meta and ext.meta.installed_release - else None - ), - "payToEnable": ( - dict(ext.meta.pay_to_enable) - if ext.meta and ext.meta.pay_to_enable - else {} - ), - "isPaymentRequired": ext.requires_payment, - "inProgress": False, - "selectedForUpdate": False, - } - for ext in installable_exts - ] + extension_data = [] + for ext in installable_exts: + installed_ext = installed_exts_by_id.get(ext.id) + is_wasm = installed_ext.is_wasm if installed_ext else ext.is_wasm + icon = wasm_extension_icon_url(ext.id) if is_wasm else ext.icon + permissions = ( + validate_extension_permissions( + installed_ext.id, installed_ext.permissions, strict=False + ) + if installed_ext + else [] + ) + extension_data.append( + { + "id": ext.id, + "name": ext.name, + "icon": icon, + "shortDescription": ext.short_description, + "stars": ext.stars, + "isFeatured": ext.meta.featured if ext.meta else False, + "categories": ext.meta.categories if ext.meta else [], + "dependencies": ext.meta.dependencies if ext.meta else "", + "isInstalled": ext.id in installed_exts_ids, + "hasDatabaseTables": next( + (True for version in db_versions if version.db == ext.id), False + ), + "isAvailable": ext.id in all_ext_ids, + "isAdminOnly": ext.id in settings.lnbits_admin_extensions, + "isActive": ext.id not in inactive_extensions, + "latestRelease": ( + dict(ext.meta.latest_release) + if ext.meta and ext.meta.latest_release + else None + ), + "hasPaidRelease": ext.meta.has_paid_release if ext.meta else False, + "hasFreeRelease": ext.meta.has_free_release if ext.meta else False, + "paidFeatures": ext.meta.paid_features if ext.meta else False, + "installedRelease": ( + dict(ext.meta.installed_release) + if ext.meta and ext.meta.installed_release + else None + ), + "payToEnable": ( + dict(ext.meta.pay_to_enable) + if ext.meta and ext.meta.pay_to_enable + else {} + ), + "isPaymentRequired": ext.requires_payment, + "isWasm": is_wasm, + "permissions": [dict(permission) for permission in permissions], + "inProgress": False, + "selectedForUpdate": False, + } + ) return extension_data @@ -646,3 +1003,178 @@ async def create_extension_review( resp.raise_for_status() payment_request = resp.json() return ExtensionReviewPaymentRequest(**payment_request) + + +async def _require_active_wasm_extension(ext_id: str) -> InstallableExtension: + installed_ext = await get_installed_extension(ext_id) + if not installed_ext or not installed_ext.active: + raise HTTPException( + HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active." + ) + if not installed_ext.is_wasm: + raise HTTPException( + HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' is not a WASM extension." + ) + return installed_ext + + +def _safe_user_extension_permissions(permissions: dict | None) -> dict: + safe_permissions: dict[str, list[dict]] = {} + for permission_id, grants in (permissions or {}).items(): + if not isinstance(permission_id, str) or not isinstance(grants, list): + continue + safe_grants = [ + grant + for grant in grants + if isinstance(grant, dict) and isinstance(grant.get("id"), str) + ] + if safe_grants: + safe_permissions[permission_id] = safe_grants + return safe_permissions + + +def _user_permission_grant_id_for_wallet( + permissions: dict, permission_id: str, wallet_id: str +) -> str | None: + grants = permissions.get(permission_id) + if not isinstance(grants, list): + return None + + for grant in grants: + if not isinstance(grant, dict) or grant.get("wallet_id") != wallet_id: + continue + grant_id = grant.get("id") + return grant_id if isinstance(grant_id, str) and grant_id else None + return None + + +def _remove_user_permission_grant(permissions: dict, grant_id: str) -> dict: + updated_permissions = dict(permissions or {}) + for permission_id, grants in list(updated_permissions.items()): + if not isinstance(grants, list): + continue + + remaining_grants = [ + grant + for grant in grants + if not isinstance(grant, dict) or grant.get("id") != grant_id + ] + if remaining_grants: + updated_permissions[permission_id] = remaining_grants + else: + updated_permissions.pop(permission_id, None) + return updated_permissions + + +async def _check_background_payment_permission( + account_id: str, + permissions: dict, + grant_data: dict, +) -> ExtensionPermissionCheckResult: + try: + data = ExtensionBackgroundPaymentGrantRequest.parse_obj(grant_data) + except ValueError as exc: + raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc + + wallet = await get_wallet(data.wallet_id) + if not wallet or wallet.user != account_id: + raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.") + if wallet.is_lightning_shared_wallet: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "Background payments are not allowed from shared wallets.", + ) + if not wallet.can_send_payments: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "This wallet cannot send payments.", + ) + + requested_grant = data.to_grant() + existing_grant = _find_background_payment_grant( + permissions, requested_grant.wallet_id + ) + covered = ( + existing_grant is not None + and existing_grant.enabled + and existing_grant.max_amount >= requested_grant.max_amount + and _background_destination_policy_covers( + existing_grant.destination_policy, requested_grant.destination_policy + ) + ) + grant = existing_grant if covered and existing_grant else requested_grant + return ExtensionPermissionCheckResult( + id=WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, + approved=covered, + grant=json.loads(grant.json()), + ) + + +async def _check_wallet_payments_watch_permission( + account_id: str, + permissions: dict, + grant_data: dict, +) -> ExtensionPermissionCheckResult: + try: + data = ExtensionWalletPaymentsWatchGrantRequest.parse_obj(grant_data) + except ValueError as exc: + raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc + + wallet = await get_wallet(data.wallet_id) + if not wallet or wallet.user != account_id: + raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.") + + existing_grant = _find_wallet_payments_watch_grant(permissions, data.wallet_id) + covered = bool(existing_grant and existing_grant.enabled) + grant = existing_grant if covered and existing_grant else data.to_grant() + return ExtensionPermissionCheckResult( + id=WALLET_PAYMENTS_WATCH_PERMISSION, + approved=covered, + grant=json.loads(grant.json()), + ) + + +def _find_background_payment_grant( + permissions: dict, wallet_id: str +) -> ExtensionBackgroundPaymentGrant | None: + grants = permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION) + if not isinstance(grants, list): + return None + for grant_data in grants: + if not isinstance(grant_data, dict): + continue + try: + grant = ExtensionBackgroundPaymentGrant.parse_obj(grant_data) + except ValueError: + continue + if grant.wallet_id == wallet_id: + return grant + return None + + +def _find_wallet_payments_watch_grant( + permissions: dict, wallet_id: str +) -> ExtensionWalletPaymentsWatchGrant | None: + grants = permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION) + if not isinstance(grants, list): + return None + for grant_data in grants: + if not isinstance(grant_data, dict): + continue + try: + grant = ExtensionWalletPaymentsWatchGrant.parse_obj(grant_data) + except ValueError: + continue + if grant.wallet_id == wallet_id: + return grant + return None + + +def _background_destination_policy_covers( + existing: ExtensionBackgroundPaymentDestinationPolicy, + requested: ExtensionBackgroundPaymentDestinationPolicy, +) -> bool: + return ( + existing == requested + or existing == ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED + ) diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index e073a5c4f..0a26444f5 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -189,6 +189,9 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)] @generic_router.get("/audit", dependencies=admin_ui_checks) @generic_router.get("/node", dependencies=admin_ui_checks) @generic_router.get("/admin", dependencies=admin_ui_checks) +@generic_router.get("/admin/extensions/wasm", dependencies=admin_ui_checks) +@generic_router.get("/admin/extensions/wasm/limits", dependencies=admin_ui_checks) +@generic_router.get("/admin/extensions/wasm/{ext_id}", dependencies=admin_ui_checks) @generic_router.get( "/extensions/builder", dependencies=[Depends(check_extension_builder)] ) @@ -196,7 +199,9 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)] "/extensions/builder/preview", dependencies=[Depends(check_extension_builder)] ) async def index( - request: Request, user: User = Depends(check_user_exists) + request: Request, + ext_id: str | None = None, + user: User = Depends(check_user_exists), ) -> HTMLResponse: return template_renderer().TemplateResponse( request, diff --git a/lnbits/core/wasm_ext/__init__.py b/lnbits/core/wasm_ext/__init__.py new file mode 100644 index 000000000..3ac850ed1 --- /dev/null +++ b/lnbits/core/wasm_ext/__init__.py @@ -0,0 +1,24 @@ +from .api.host import ExtensionHostAPI +from .api.models import ExtensionAPIMethod, ExtensionAPIMethodExport +from .api.registry import ( + extension_api_contract, + extension_api_method, + extension_api_permission_ids, + get_extension_api_method, + list_extension_api_methods, +) +from .api.runtime import ExtensionAPIHost +from .wasm.loader import WasmExtension + +__all__ = [ + "ExtensionAPIHost", + "ExtensionAPIMethod", + "ExtensionAPIMethodExport", + "ExtensionHostAPI", + "WasmExtension", + "extension_api_contract", + "extension_api_method", + "extension_api_permission_ids", + "get_extension_api_method", + "list_extension_api_methods", +] diff --git a/lnbits/core/wasm_ext/api/__init__.py b/lnbits/core/wasm_ext/api/__init__.py new file mode 100644 index 000000000..1ad668781 --- /dev/null +++ b/lnbits/core/wasm_ext/api/__init__.py @@ -0,0 +1,22 @@ +from .host import ExtensionHostAPI +from .models import ExtensionAPIMethod, ExtensionAPIMethodExport +from .registry import ( + extension_api_contract, + extension_api_method, + extension_api_permission_ids, + get_extension_api_method, + list_extension_api_methods, +) +from .runtime import ExtensionAPIHost + +__all__ = [ + "ExtensionAPIHost", + "ExtensionAPIMethod", + "ExtensionAPIMethodExport", + "ExtensionHostAPI", + "extension_api_contract", + "extension_api_method", + "extension_api_permission_ids", + "get_extension_api_method", + "list_extension_api_methods", +] diff --git a/lnbits/core/wasm_ext/api/background_payments.py b/lnbits/core/wasm_ext/api/background_payments.py new file mode 100644 index 000000000..640a3df52 --- /dev/null +++ b/lnbits/core/wasm_ext/api/background_payments.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import Any, NoReturn + +from bolt11 import decode as bolt11_decode +from loguru import logger + +from lnbits.core.crud.extensions import get_user_extension +from lnbits.core.crud.payments import check_internal +from lnbits.core.crud.wallets import get_wallet +from lnbits.core.models.extensions import ( + ExtensionBackgroundPaymentDestinationPolicy, + ExtensionBackgroundPaymentGrant, +) +from lnbits.core.models.wallets import Wallet + +WALLET_PAY_INVOICE_BACKGROUND_PERMISSION = "wallet.pay_invoice_background" + + +async def background_payment_extra( + *, + extension_id: str, + wallet: Wallet, + payment_request: str, + amount_msat: int, +) -> dict[str, Any]: + grant = await _background_payment_grant(extension_id, wallet, amount_msat) + await _check_destination_policy(extension_id, wallet, grant, payment_request) + + return { + "tag": extension_id, + "extension": extension_id, + "background_payment": True, + "background_permission": WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, + "background_wallet_id": wallet.source_wallet_id, + "background_destination_policy": grant.destination_policy.value, + } + + +def invoice_amount_msat(payment_request: str) -> int: + invoice = bolt11_decode(payment_request) + amount_msat = int(invoice.amount_msat or 0) + if amount_msat <= 0: + raise PermissionError("Background payments require an invoice amount.") + return amount_msat + + +async def _background_payment_grant( + extension_id: str, + wallet: Wallet, + amount_msat: int, +) -> ExtensionBackgroundPaymentGrant: + if wallet.is_lightning_shared_wallet: + _deny(extension_id, wallet, amount_msat, "shared wallet") + if not wallet.can_send_payments: + _deny(extension_id, wallet, amount_msat, "wallet cannot send payments") + + user_extension = await get_user_extension(wallet.user, extension_id) + if not user_extension or not user_extension.active: + _deny(extension_id, wallet, amount_msat, "extension disabled for user") + + permissions = user_extension.permissions or {} + grants = permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION) + if not isinstance(grants, list): + _deny(extension_id, wallet, amount_msat, "missing background payment grant") + + grant = _find_wallet_grant(grants, wallet.id) + if not grant: + _deny(extension_id, wallet, amount_msat, "missing wallet background grant") + if not grant.enabled: + _deny(extension_id, wallet, amount_msat, "background grant disabled") + if amount_msat > grant.max_amount * 1000: + _deny(extension_id, wallet, amount_msat, "payment exceeds max amount") + return grant + + +def _find_wallet_grant( + grants: list[Any], wallet_id: str +) -> ExtensionBackgroundPaymentGrant | None: + for grant_data in grants: + if not isinstance(grant_data, dict): + continue + try: + grant = ExtensionBackgroundPaymentGrant.parse_obj(grant_data) + except ValueError: + continue + if grant.wallet_id == wallet_id: + return grant + return None + + +async def _check_destination_policy( + extension_id: str, + wallet: Wallet, + grant: ExtensionBackgroundPaymentGrant, + payment_request: str, +) -> None: + if ( + grant.destination_policy + == ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED + ): + return + + payment_hash = bolt11_decode(payment_request).payment_hash + internal_payment = await check_internal(payment_hash) + if not internal_payment: + _deny(extension_id, wallet, 0, "external destination not allowed") + + destination_wallet = await get_wallet(internal_payment.wallet_id) + if not destination_wallet or destination_wallet.user != wallet.user: + _deny(extension_id, wallet, 0, "destination wallet is not owned by user") + + +def _deny(extension_id: str, wallet: Wallet, amount_msat: int, reason: str) -> NoReturn: + logger.warning( + "WASM extension '{}' denied background payment from wallet '{}', " + "user '{}', amount_msat '{}': {}.", + extension_id, + wallet.id, + wallet.user, + amount_msat, + reason, + ) + raise PermissionError(reason) diff --git a/lnbits/core/wasm_ext/api/host.py b/lnbits/core/wasm_ext/api/host.py new file mode 100644 index 000000000..ac9a0075b --- /dev/null +++ b/lnbits/core/wasm_ext/api/host.py @@ -0,0 +1,718 @@ +from __future__ import annotations + +import json +import logging +import secrets +import time +from collections.abc import Iterable, Mapping +from typing import Any + +from lnbits.helpers import sha256s + +from ..client.extensions import send_extension_api_request +from ..storage.crud import ( + storage_delete_row, + storage_get_paginated_rows, + storage_get_public_row, + storage_get_row, + storage_set_row, +) +from .background_payments import ( + WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, + background_payment_extra, + invoice_amount_msat, +) +from .models import ( + CreateInvoicePublicRequest, + CreateInvoiceRequest, + CreateInvoiceResponse, + EmptyRequest, + ExtensionApiRequest, + HttpRequest, + HttpResponse, + ListUserWalletsResponse, + LogRequest, + LogResponse, + NowResponse, + PayInvoiceRequest, + PayInvoiceResponse, + PayLnurlRequest, + RandomIdRequest, + RandomIdResponse, + StorageDeleteRequest, + StorageDeleteResponse, + StorageGetRequest, + StorageGetResponse, + StoragePaginatedRequest, + StoragePaginatedResponse, + StorageSetRequest, + StorageSetResponse, + UserWalletSummary, + WalletBalanceRequest, + WalletBalanceResponse, +) +from .registry import extension_api_method + +logger = logging.getLogger("lnbits.extensions") + + +class ExtensionHostAPI: + def __init__( + self, + extension_id: str, + permissions: Iterable[Any], + *, + user_id: str | None = None, + access_token: str | None = None, + context: str = "user", + owner_id: str | None = None, + invocation_id: str | None = None, + runtime_limits: dict[str, int] | None = None, + ) -> None: + self.extension_id = extension_id + self.permissions, self.permission_policies = self._permission_data(permissions) + self.user_id = user_id + self.access_token = access_token + self.context = context + self.owner_id = sha256s(user_id) if user_id else owner_id + self.invocation_id = invocation_id + self.runtime_limits = runtime_limits or {} + from .utils import ExtensionAPIUtils + + self.utils = ExtensionAPIUtils( + self.extension_id, + self.permissions, + authenticated=self.has_authenticated_context(), + ) + + @extension_api_method( + method_id="storage.get", + namespace="storage", + name="Get storage row", + host_name="storage_get", + sdk_name="get", + description="Read one row from an extension storage table.", + required_permission="ext.storage.read", + require_auth=True, + ) + async def storage_get(self, request: StorageGetRequest) -> StorageGetResponse: + row = await storage_get_row( + self.extension_id, + request.table, + request.id, + self._require_owner_id(), + ) + return StorageGetResponse(data_json=json.dumps(row) if row else None) + + @extension_api_method( + method_id="storage.get_public", + namespace="storage", + name="Get public storage row", + host_name="storage_get_public", + sdk_name="getPublic", + description="Read one public row from an extension storage table.", + required_permission="ext.storage.read_public", + require_auth=False, + ) + async def storage_get_public( + self, request: StorageGetRequest + ) -> StorageGetResponse: + public_fields = self._public_storage_fields(request.table) + row = await storage_get_public_row(self.extension_id, request.table, request.id) + if not row: + return StorageGetResponse() + public_row = { + field_name: value + for field_name, value in row.items() + if field_name in public_fields + } + # todo: check public fields filtering + return StorageGetResponse(data_json=json.dumps(public_row)) + + @extension_api_method( + method_id="storage.set", + namespace="storage", + name="Set storage row", + host_name="storage_set", + sdk_name="set", + description="Create or update one row in an extension storage table.", + required_permission="ext.storage.write", + require_auth=True, + ) + async def storage_set(self, request: StorageSetRequest) -> StorageSetResponse: + await storage_set_row( + self.extension_id, + request.table, + request.data, + self._require_owner_id(), + ) + return StorageSetResponse() + + @extension_api_method( + method_id="storage.get_paginated", + namespace="storage", + name="Get paginated storage rows", + host_name="storage_get_paginated", + sdk_name="getPaginated", + description="Get filtered, searched, sorted, paginated storage rows.", + required_permission="ext.storage.read", + require_auth=True, + ) + async def storage_get_paginated( + self, request: StoragePaginatedRequest + ) -> StoragePaginatedResponse: + page = await storage_get_paginated_rows( + self.extension_id, + request.table, + request.filters, + owner_id=self._require_owner_id(), + search=request.search, + search_fields=request.search_fields, + sort_by=request.sort_by, + descending=request.descending, + limit=request.limit, + offset=request.offset, + ) + return StoragePaginatedResponse( + rows_json=json.dumps(page["data"]), + total=page["total"], + ) + + @extension_api_method( + method_id="storage.delete", + namespace="storage", + name="Delete storage row", + host_name="storage_delete", + sdk_name="delete", + description="Delete one row from an extension storage table.", + required_permission="ext.storage.write", + require_auth=True, + ) + async def storage_delete( + self, request: StorageDeleteRequest + ) -> StorageDeleteResponse: + await storage_delete_row( + self.extension_id, + request.table, + request.id, + self._require_owner_id(), + ) + return StorageDeleteResponse() + + @extension_api_method( + method_id="wallet.create_invoice", + namespace="wallet", + name="Create invoice", + host_name="create_invoice", + sdk_name="createInvoice", + description="Create an incoming Lightning invoice for an allowed wallet.", + required_permission="wallet.create_invoice", + require_auth=True, + ) + async def wallet_create_invoice( + self, request: CreateInvoiceRequest + ) -> CreateInvoiceResponse: + from lnbits.core.crud.wallets import get_wallet + from lnbits.core.models.payments import CreateInvoice + from lnbits.core.services.payments import create_payment_request + + if not self.user_id: + raise PermissionError( + "Creating an invoice for this wallet requires an " + "authenticated user context." + ) + wallet = await get_wallet(request.wallet_id) + if wallet is None or wallet.user != self.user_id: + raise PermissionError("Not your wallet.") + + payment = await create_payment_request( + request.wallet_id, + CreateInvoice( + amount=request.amount, + unit=request.currency, + memo=request.memo, + extra=request.extra, + extension=self.extension_id, + ), + ) + return CreateInvoiceResponse( + payment_hash=payment.payment_hash, + payment_request=payment.payment_request or payment.bolt11, + checking_id=payment.checking_id, + ) + + @extension_api_method( + method_id="wallet.create_invoice_public", + namespace="wallet", + name="Create public invoice", + host_name="create_invoice_public", + sdk_name="createInvoicePublic", + description="Create a public incoming Lightning invoice.", + required_permission="wallet.create_invoice_public", + require_auth=False, + ) + async def wallet_create_invoice_public( + self, request: CreateInvoicePublicRequest + ) -> CreateInvoiceResponse: + from lnbits.core.models.payments import CreateInvoice + from lnbits.core.services.payments import create_payment_request + + row: dict[str, Any] | None = None + wallet_field = "" + for policy in self._public_invoice_wallet_sources(): + row = await storage_get_public_row( + self.extension_id, + policy["table"], + request.source_id, + ) + if row: + wallet_field = policy["wallet_field"] + break + + if not row: + raise PermissionError("Public invoice source was not found.") + + wallet_id = row.get(wallet_field) + if not isinstance(wallet_id, str) or not wallet_id: + raise PermissionError("Public invoice source has no valid wallet.") + + payment = await create_payment_request( + wallet_id, + CreateInvoice( + amount=request.amount, + unit=request.currency, + memo=request.memo, + extra={ + "tag": self.extension_id, + "source_id": request.source_id, + f"extra_{self.extension_id}": request.extra, + }, + extension=self.extension_id, + ), + ) + return CreateInvoiceResponse( + payment_hash=payment.payment_hash, + payment_request=payment.payment_request or payment.bolt11, + checking_id=payment.checking_id, + ) + + @extension_api_method( + method_id="wallet.list_user_wallets", + namespace="wallet", + name="List user wallets", + host_name="list_user_wallets", + sdk_name="listUserWallets", + description="List wallets available to the authenticated extension user.", + required_permission="wallet.list", + ) + async def wallet_list_user_wallets( + self, request: EmptyRequest + ) -> ListUserWalletsResponse: + if not self.user_id: + raise PermissionError( + "Listing user wallets requires an authenticated user context." + ) + + from lnbits.core.crud.wallets import get_wallets + + user_wallets = await get_wallets(self.user_id) + if user_wallets is None: + raise PermissionError( + "Listing user wallets requires an authenticated user context." + ) + return ListUserWalletsResponse( + wallets=[ + UserWalletSummary(id=w.id, name=w.name, currency=w.currency) + for w in user_wallets + ] + ) + + @extension_api_method( + method_id="wallet.balance", + namespace="wallet", + name="Read wallet balance", + host_name="wallet_balance", + sdk_name="balance", + description="Read the balance of a wallet available to the user.", + required_permission="wallet.balance.read", + ) + async def wallet_balance( + self, request: WalletBalanceRequest + ) -> WalletBalanceResponse: + from lnbits.core.crud.wallets import get_wallet + + if not self.user_id: + raise PermissionError( + "Reading a wallet balance requires an authenticated user context." + ) + + wallet = await get_wallet(request.wallet_id) + if wallet is None or wallet.user != self.user_id: + raise PermissionError("Reading this wallet balance is not allowed.") + + withdrawable_msat = max(wallet.withdrawable_balance, 0) + fee_reserve_msat = max(wallet.balance_msat - withdrawable_msat, 0) + return WalletBalanceResponse( + wallet_id=wallet.id, + name=wallet.name, + currency=wallet.currency, + balance_msat=wallet.balance_msat, + balance_sat=wallet.balance, + withdrawable_msat=withdrawable_msat, + withdrawable_sat=withdrawable_msat // 1000, + fee_reserve_msat=fee_reserve_msat, + fee_reserve_sat=fee_reserve_msat // 1000, + can_send_payments=wallet.can_send_payments, + ) + + @extension_api_method( + method_id="wallet.pay_invoice", + namespace="wallet", + name="Pay invoice", + host_name="pay_invoice", + sdk_name="payInvoice", + description="Pay a Lightning invoice from a wallet available to the user.", + ) + async def wallet_pay_invoice( + self, request: PayInvoiceRequest + ) -> PayInvoiceResponse: + from lnbits.core.crud.wallets import get_wallet + from lnbits.core.services.payments import pay_invoice + from lnbits.exceptions import PaymentError + + wallet = await get_wallet(request.wallet_id) + if wallet is None: + raise PermissionError("Paying invoices from this wallet is not allowed.") + + try: + if self.user_id: + self.require_permission("wallet.pay_invoice") + if wallet.user != self.user_id: + raise PermissionError( + "Paying invoices from this wallet is not allowed." + ) + payment = await pay_invoice( + wallet_id=request.wallet_id, + payment_request=request.payment_request, + max_sat=request.max_sat, + extra={"tag": self.extension_id, **request.extra}, + description=request.description, + tag=self.extension_id, + ) + else: + self.require_permission(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION) + amount_msat = invoice_amount_msat(request.payment_request) + extra = await background_payment_extra( + extension_id=self.extension_id, + wallet=wallet, + payment_request=request.payment_request, + amount_msat=amount_msat, + ) + payment = await pay_invoice( + wallet_id=request.wallet_id, + payment_request=request.payment_request, + max_sat=request.max_sat, + extra={**request.extra, **extra}, + description=request.description, + tag=self.extension_id, + ) + except (PaymentError, PermissionError, ValueError) as exc: + return PayInvoiceResponse(ok=False, error=str(exc)) + + return _pay_invoice_response(payment) + + @extension_api_method( + method_id="wallet.pay_lnurl", + namespace="wallet", + name="Pay LNURL", + host_name="pay_lnurl", + sdk_name="payLnurl", + description="Pay a Lightning Address or LNURL-pay request from a wallet.", + ) + async def wallet_pay_lnurl(self, request: PayLnurlRequest) -> PayInvoiceResponse: + from lnurl import LnAddressError, LnurlResponseException + + from lnbits.core.crud.wallets import get_wallet + from lnbits.core.models.lnurl import CreateLnurlPayment + from lnbits.core.services.lnurl import fetch_lnurl_pay_request + from lnbits.core.services.payments import pay_invoice + from lnbits.exceptions import PaymentError + + from .lnurl import ( + lnurl_for_core, + lnurl_pay_response_text, + lnurl_payment_amount_for_core, + lnurl_payment_unit_for_core, + ) + + wallet = await get_wallet(request.wallet_id) + if wallet is None: + raise PermissionError("Paying from this wallet is not allowed.") + + try: + if self.user_id: + self.require_permission("wallet.pay_invoice") + if wallet.user != self.user_id: + raise PermissionError("Paying from this wallet is not allowed.") + else: + self.require_permission(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION) + + unit = lnurl_payment_unit_for_core(request.currency) + res, action = await fetch_lnurl_pay_request( + data=CreateLnurlPayment( + lnurl=lnurl_for_core(request.lnurl), + amount=lnurl_payment_amount_for_core(request.amount), + unit=unit, + comment=request.comment, + internal_memo=request.description or None, + ), + wallet=None, + ) + extra = {"tag": self.extension_id, **request.extra} + if action.successAction: + extra["success_action"] = action.successAction.json() + if request.comment: + extra["comment"] = request.comment + if unit != "sat": + extra["fiat_currency"] = unit + extra["fiat_amount"] = str(request.amount) + + if not self.user_id: + amount_msat = invoice_amount_msat(str(action.pr)) + extra = { + **extra, + **( + await background_payment_extra( + extension_id=self.extension_id, + wallet=wallet, + payment_request=str(action.pr), + amount_msat=amount_msat, + ) + ), + } + + payment = await pay_invoice( + wallet_id=request.wallet_id, + payment_request=str(action.pr), + max_sat=request.max_sat, + extra=extra, + description=request.description or lnurl_pay_response_text(res), + tag=self.extension_id, + ) + except ( + LnAddressError, + LnurlResponseException, + PaymentError, + PermissionError, + ValueError, + ) as exc: + return PayInvoiceResponse(ok=False, error=str(exc)) + + return _pay_invoice_response(payment) + + @extension_api_method( + method_id="http.request", + namespace="http", + name="HTTP request", + host_name="http_request", + sdk_name="request", + description="Make an outbound HTTP request to an allowed host.", + required_permission="http.request", + require_auth=True, + ) + async def http_request(self, request: HttpRequest) -> HttpResponse: + from ..client.http import send_extension_http_request + + policies = self.permission_policies.get("http.request") or [] + return await send_extension_http_request( + self.extension_id, + policies, + request, + timeout_ms=self.runtime_limits.get("wasm_runtime_http_timeout_ms"), + max_response_bytes=self.runtime_limits.get( + "wasm_runtime_max_http_response_bytes" + ), + ) + + @extension_api_method( + method_id="extension.api.request", + namespace="extension", + name="Extension API request", + host_name="extension_api_request", + sdk_name="request", + description="Call an allowed installed extension API.", + required_permission="extension.api.request", + require_auth=True, + ) + async def extension_api_request(self, request: ExtensionApiRequest) -> HttpResponse: + + policies = self.permission_policies.get("extension.api.request") or [] + return await send_extension_api_request( + self.extension_id, + policies, + self.user_id, + self.access_token, + request, + timeout_ms=self.runtime_limits.get("wasm_runtime_http_timeout_ms"), + max_response_bytes=self.runtime_limits.get( + "wasm_runtime_max_http_response_bytes" + ), + ) + + @extension_api_method( + method_id="system.random_id", + namespace="system", + name="Random ID", + host_name="random_id", + sdk_name="id", + description="Create a random extension-local identifier.", + require_auth=False, + ) + async def system_random_id(self, request: RandomIdRequest) -> RandomIdResponse: + return RandomIdResponse( + id=f"{request.prefix}_{secrets.token_urlsafe(12).replace('-', '_')}" + ) + + @extension_api_method( + method_id="system.now", + namespace="system", + name="Current timestamp", + host_name="now", + sdk_name="now", + description="Return the current Unix timestamp.", + require_auth=False, + ) + async def system_now(self, request: EmptyRequest) -> NowResponse: + return NowResponse(timestamp=int(time.time())) + + @extension_api_method( + method_id="system.log", + namespace="system", + name="Log message", + host_name="log", + sdk_name="log", + description="Write a bounded message to the extension log.", + require_auth=False, + ) + async def system_log(self, request: LogRequest) -> LogResponse: + log = getattr(logger, request.level) + log("extension:%s %s", self.extension_id, request.message) + return LogResponse() + + @staticmethod + def _permission_data( + permissions: Iterable[Any], + ) -> tuple[set[str], dict[str, list[Any]]]: + permission_ids: set[str] = set() + policies: dict[str, list[Any]] = {} + + for permission in permissions: + if isinstance(permission, str): + permission_ids.add(permission) + continue + + permission_id: str | None = None + permission_policies: Any = None + if isinstance(permission, Mapping): + permission_id = permission.get("id") # type: ignore[assignment] + permission_policies = permission.get("policies") + else: + permission_id = getattr(permission, "id", None) + permission_policies = getattr(permission, "policies", None) + + if not permission_id: + continue + permission_ids.add(permission_id) + if isinstance(permission_policies, list): + policies[permission_id] = permission_policies + + return permission_ids, policies + + def _public_storage_fields(self, table: str) -> set[str]: + tables = self.permission_policies.get("ext.storage.read_public") + if not isinstance(tables, list) or not tables: + raise PermissionError( + "Public storage reads require policies for " + "'ext.storage.read_public'." + ) + + for table_policy in tables: + if not isinstance(table_policy, dict): + continue + if table_policy.get("table_name") != table: + continue + public_fields = table_policy.get("public_fields") + if not isinstance(public_fields, list) or not all( + isinstance(field, str) and field for field in public_fields + ): + raise PermissionError( + f"Public storage table '{table}' has no valid public fields." + ) + return set(public_fields) + + raise PermissionError(f"Storage table '{table}' is not publicly readable.") + + def _public_invoice_wallet_sources(self) -> list[dict[str, str]]: + policies = self.permission_policies.get("wallet.create_invoice_public") + if not isinstance(policies, list) or not policies: + raise PermissionError("Public invoice creation requires a policies list.") + + sources: list[dict[str, str]] = [] + for source_policy in policies: + if not isinstance(source_policy, dict): + raise PermissionError( + "Public invoice creation policies must be objects." + ) + table = source_policy.get("table") + wallet_field = source_policy.get("wallet_field") + if not isinstance(table, str) or not table: + raise PermissionError( + "Public invoice creation requires a storage table policy." + ) + if not isinstance(wallet_field, str) or not wallet_field: + raise PermissionError( + "Public invoice creation requires a wallet field policy." + ) + sources.append({"table": table, "wallet_field": wallet_field}) + + if not sources: + raise PermissionError( + "Public invoice creation requires at least one valid policy." + ) + return sources + + def require_permission(self, permission: str | None) -> None: + if permission and permission not in self.permissions: + raise PermissionError( + f"Extension '{self.extension_id}' is missing permission '{permission}'." + ) + + def has_authenticated_context(self) -> bool: + return bool(self.user_id) or self.context == "event" + + def _require_owner_id(self) -> str: + if not self.owner_id: + raise PermissionError("Extension API method requires an owner context.") + return self.owner_id + + def __repr__(self) -> str: + return ( + "ExtensionHostAPI(" + f"extension_id={self.extension_id!r}, " + f"context={self.context!r}, " + f"owner_id={self.owner_id!r}" + ")" + ) + + +def _pay_invoice_response(payment: Any) -> PayInvoiceResponse: + return PayInvoiceResponse( + ok=True, + checking_id=payment.checking_id, + payment_hash=payment.payment_hash, + status=payment.status, + amount_msat=abs(payment.amount), + fee_msat=abs(payment.fee), + pending=payment.pending, + success=payment.success, + ) diff --git a/lnbits/core/wasm_ext/api/lnurl.py b/lnbits/core/wasm_ext/api/lnurl.py new file mode 100644 index 000000000..22f78f829 --- /dev/null +++ b/lnbits/core/wasm_ext/api/lnurl.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import json +from typing import Any + +from lnurl import LnAddress, Lnurl + + +def normalize_lnurl(value: str) -> str: + normalized = value.strip() + if normalized.lower().startswith("lightning:"): + normalized = normalized[len("lightning:") :] + if "@" in normalized: + normalized = normalized.lower() + if not normalized: + raise ValueError("LNURL is required.") + return normalized + + +def lnurl_for_core(value: str) -> Lnurl | LnAddress: + normalized = normalize_lnurl(value) + if "@" in normalized: + return LnAddress(normalized) + return Lnurl(normalized) + + +def lnurl_payment_amount_for_core(amount: float) -> int: + if amount <= 0: + raise ValueError("Amount must be greater than zero.") + return round(amount * 1000) + + +def lnurl_payment_unit_for_core(currency: str) -> str: + unit = currency.strip().lower() + if not unit: + raise ValueError("Currency is required.") + if unit in {"sat", "sats"}: + return "sat" + return unit.upper() + + +def lnurl_pay_response_metadata_json(response: Any) -> str: + metadata = getattr(response, "metadata", None) + if metadata is None: + return "[]" + + metadata_list = getattr(metadata, "list", None) + try: + if callable(metadata_list): + return json.dumps(metadata_list()) + return json.dumps(metadata) + except TypeError: + return json.dumps(str(metadata)) + + +def lnurl_pay_response_text(response: Any) -> str: + description = getattr(response, "description", None) + if description is not None: + return str(description) + + metadata = getattr(response, "metadata", None) + text = getattr(metadata, "text", None) + return str(text) if text is not None else "" + + +def lnurl_pay_response_int(response: Any, snake_name: str, camel_name: str) -> int: + value = getattr(response, snake_name, None) + if value is None: + value = getattr(response, camel_name, 0) + return int(value or 0) diff --git a/lnbits/core/wasm_ext/api/models.py b/lnbits/core/wasm_ext/api/models.py new file mode 100644 index 000000000..028ef3ea0 --- /dev/null +++ b/lnbits/core/wasm_ext/api/models.py @@ -0,0 +1,387 @@ +import json +from dataclasses import dataclass +from typing import Any, Literal + +from pydantic import BaseModel, Field, root_validator + + +@dataclass(frozen=True) +class ExtensionAPIMethodExport: + method_id: str + namespace: str + name: str + host_interface: str + host_name: str + sdk_name: str + description: str + required_permission: str | None = None + require_auth: bool = True + + +@dataclass(frozen=True) +class ExtensionAPIMethod: + method_id: str + namespace: str + name: str + python_name: str + host_interface: str + host_name: str + sdk_name: str + description: str + request_model: type[BaseModel] + response_model: type[BaseModel] + required_permission: str | None = None + require_auth: bool = True + + @property + def sdk_qualified_name(self) -> str: + return f"{self.namespace}.{self.sdk_name}" + + +class EmptyRequest(BaseModel): + pass + + +class StorageGetRequest(BaseModel): + table: str = Field(..., min_length=1, max_length=128) + id: str = Field(..., min_length=1, max_length=512) + + +class StorageGetResponse(BaseModel): + data_json: str | None = None + + +class StorageSetRequest(BaseModel): + table: str = Field(..., min_length=1, max_length=128) + data: dict[str, Any] = Field(default_factory=dict) + + @root_validator(pre=True) + def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]: + data_json = values.get("data_json") + if data_json is not None and "data" not in values: + values["data"] = json.loads(data_json) + return values + + +class StorageSetResponse(BaseModel): + ok: bool = True + + +class StoragePaginatedRequest(BaseModel): + table: str = Field(..., min_length=1, max_length=128) + filters: dict[str, Any] = Field(default_factory=dict) + search: str | None = Field(None, max_length=256) + search_fields: list[str] = Field(default_factory=list) + sort_by: str | None = Field(None, min_length=1, max_length=128) + descending: bool = False + limit: int = Field(25, ge=1, le=1000) + offset: int = Field(0, ge=0) + + @root_validator(pre=True) + def parse_json_fields(cls, values: dict[str, Any]) -> dict[str, Any]: + filters_json = values.get("filters_json") + if filters_json is not None and "filters" not in values: + values["filters"] = json.loads(filters_json) + + search_fields_json = values.get("search_fields_json") + if search_fields_json is not None and "search_fields" not in values: + values["search_fields"] = json.loads(search_fields_json) + + if values.get("sort_by") == "": + values["sort_by"] = None + return values + + +class StoragePaginatedResponse(BaseModel): + rows_json: str = "[]" + total: int = 0 + + +class StorageDeleteRequest(BaseModel): + table: str = Field(..., min_length=1, max_length=128) + id: str = Field(..., min_length=1, max_length=512) + + +class StorageDeleteResponse(BaseModel): + ok: bool = True + + +class CreateInvoiceRequest(BaseModel): + wallet_id: str = Field(..., min_length=1, max_length=128) + amount: float = Field(..., gt=0) + currency: str = Field("sat", min_length=1, max_length=8) + memo: str = Field(..., max_length=512) + tag: str = Field(..., min_length=1, max_length=64) + extra: dict[str, str] = Field(default_factory=dict) + + +class CreateInvoicePublicRequest(BaseModel): + source_id: str = Field( + ..., + min_length=1, + max_length=512, + description="The source ID (entry id) of the wallet to create the invoice for.", + ) + amount: float = Field(..., gt=0) + currency: str = Field(..., min_length=1, max_length=8) + memo: str = Field("", max_length=512) + extra: dict[str, Any] = Field(default_factory=dict) + + @root_validator + def validate_extra_size(cls, values: dict[str, Any]) -> dict[str, Any]: + extra = values.get("extra") or {} + try: + encoded = json.dumps(extra, separators=(",", ":")) + except TypeError as exc: + raise ValueError("extra must be JSON serializable.") from exc + if len(encoded.encode()) > 4096: + raise ValueError("extra must not exceed 4096 bytes.") + values["extra"] = extra + return values + + +class CreateInvoiceResponse(BaseModel): + payment_hash: str + payment_request: str + checking_id: str + + +class UserWalletSummary(BaseModel): + id: str + name: str + currency: str | None = None + + +class ListUserWalletsResponse(BaseModel): + wallets: list[UserWalletSummary] = Field(default_factory=list) + + +class WalletBalanceRequest(BaseModel): + wallet_id: str = Field(..., min_length=1, max_length=128) + + +class WalletBalanceResponse(BaseModel): + wallet_id: str + name: str + currency: str | None = None + balance_msat: int + balance_sat: int + withdrawable_msat: int + withdrawable_sat: int + fee_reserve_msat: int + fee_reserve_sat: int + can_send_payments: bool + + +class PayInvoiceRequest(BaseModel): + wallet_id: str = Field(..., min_length=1, max_length=128) + payment_request: str = Field(..., min_length=1, max_length=8192) + max_sat: int | None = Field(None, gt=0) + description: str = Field("", max_length=512) + extra: dict[str, str] = Field(default_factory=dict) + + +class PayLnurlRequest(BaseModel): + wallet_id: str = Field(..., min_length=1, max_length=128) + lnurl: str = Field(..., min_length=1, max_length=2048) + amount: float = Field(..., gt=0) + currency: str = Field("sat", min_length=1, max_length=8) + comment: str | None = Field(None, max_length=512) + description: str = Field("", max_length=512) + max_sat: int | None = Field(None, gt=0) + extra: dict[str, str] = Field(default_factory=dict) + + +class PayInvoiceResponse(BaseModel): + ok: bool = True + error: str | None = None + checking_id: str | None = None + payment_hash: str | None = None + status: str | None = None + amount_msat: int = 0 + fee_msat: int = 0 + pending: bool = False + success: bool = False + + +class HttpRequest(BaseModel): + method: Literal["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"] = "GET" + url: str = Field(..., min_length=1, max_length=2048) + headers: dict[str, str] = Field(default_factory=dict) + body: str | None = Field(None, max_length=65536) + + @root_validator(pre=True) + def normalize_method(cls, values: dict[str, Any]) -> dict[str, Any]: + method = values.get("method") + if isinstance(method, str): + values["method"] = method.upper() + return values + + @root_validator + def validate_headers_size(cls, values: dict[str, Any]) -> dict[str, Any]: + headers = values.get("headers") or {} + if len(headers) > 32: + raise ValueError("headers must not contain more than 32 entries.") + for key, value in headers.items(): + if len(key) > 128 or len(value) > 4096: + raise ValueError("headers are too large.") + values["headers"] = headers + return values + + +class HttpResponse(BaseModel): + status_code: int + headers: dict[str, str] = Field(default_factory=dict) + body: str = "" + + +class ExtensionApiRequest(BaseModel): + extension_id: str = Field(..., min_length=1, max_length=128) + method: Literal["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"] = "GET" + path: str = Field(..., min_length=1, max_length=2048) + body: str | None = Field(None, max_length=65536) + + @root_validator(pre=True) + def normalize_method(cls, values: dict[str, Any]) -> dict[str, Any]: + method = values.get("method") + if isinstance(method, str): + values["method"] = method.upper() + return values + + +class CurrencyListResponse(BaseModel): + currencies: list[str] = Field(default_factory=list) + + +class CurrencyRateRequest(BaseModel): + currency: str = Field(..., min_length=1, max_length=8) + + +class CurrencyRateResponse(BaseModel): + rate: float + price: float + + +class CurrencyConvertRequest(BaseModel): + amount: float = Field(..., gt=0) + from_currency: str = Field(..., alias="from", min_length=1, max_length=8) + to: str = Field(..., min_length=1, max_length=256) + + class Config: + allow_population_by_field_name = True + + +class CurrencyConvertResponse(BaseModel): + amounts: list[tuple[str, float]] = Field(default_factory=list) + + +class FiatToSatsRequest(BaseModel): + amount: float = Field(..., gt=0) + currency: str = Field(..., min_length=1, max_length=8) + + +class FiatToSatsResponse(BaseModel): + amount_sat: int + + +class SatsToFiatRequest(BaseModel): + amount: float = Field(..., gt=0) + currency: str = Field(..., min_length=1, max_length=8) + + +class SatsToFiatResponse(BaseModel): + amount: float + + +class LnurlResolveRequest(BaseModel): + lnurl: str = Field(..., min_length=1, max_length=2048) + + +class LnurlResolveResponse(BaseModel): + lnurl: str + domain: str | None = None + description: str = "" + min_sendable_msat: int + max_sendable_msat: int + comment_allowed: int = 0 + fixed: bool = False + image: str | None = None + metadata_json: str = "[]" + + +class ServerHealthResponse(BaseModel): + server_time: int + up_time: str + + +class Bolt11Request(BaseModel): + bolt11: str = Field(..., min_length=1, max_length=8192) + + +class DecodeInvoiceResponse(BaseModel): + valid: bool = True + payment_hash: str | None = None + amount_msat: int | None = None + expiry: int | None = None + expires_at: int | None = None + memo: str | None = None + + +class ValidateInvoiceResponse(BaseModel): + valid: bool + error: str | None = None + + +class InvoicePaymentHashResponse(BaseModel): + payment_hash: str + + +class InvoiceAmountMsatResponse(BaseModel): + amount_msat: int | None = None + + +class InvoiceExpiryResponse(BaseModel): + expires_at: int | None = None + + +class InvoiceMemoResponse(BaseModel): + memo: str | None = None + + +class VerifyPreimageRequest(BaseModel): + preimage: str = Field(..., min_length=64, max_length=64) + payment_hash: str = Field(..., min_length=64, max_length=64) + + +class VerifyPreimageResponse(BaseModel): + valid: bool + + +class RandomSecretAndHashRequest(BaseModel): + length: int = Field(32, ge=16, le=64) + + +class RandomSecretAndHashResponse(BaseModel): + secret: str + hash: str + + +class RandomIdRequest(BaseModel): + prefix: str = Field(..., min_length=1, max_length=32) + + +class RandomIdResponse(BaseModel): + id: str + + +class NowResponse(BaseModel): + timestamp: int + + +class LogRequest(BaseModel): + level: Literal["debug", "info", "warning", "error"] = "info" + message: str = Field(..., min_length=1, max_length=2048) + + +class LogResponse(BaseModel): + ok: bool = True diff --git a/lnbits/core/wasm_ext/api/permissions.py b/lnbits/core/wasm_ext/api/permissions.py new file mode 100644 index 000000000..5c66228a9 --- /dev/null +++ b/lnbits/core/wasm_ext/api/permissions.py @@ -0,0 +1,250 @@ +from collections.abc import Iterable +from typing import Any + +from lnbits.core.models.extensions import ExtensionPermission, InstallableExtension +from lnbits.core.wasm_ext.api.registry import extension_api_permission_ids +from lnbits.core.wasm_ext.client.http import _request_origin +from lnbits.core.wasm_ext.wasm.config import ( + WasmExtensionConfig, + parse_wasm_extension_config, +) + +_POLICY_AWARE_PERMISSION_IDS = { + "ext.storage.read_public", + "extension.api.request", + "http.request", + "wallet.create_invoice_public", +} + + +def validate_extension_permissions( + ext_id: str, + permissions: Iterable[ExtensionPermission], + *, + strict: bool = True, +) -> list[ExtensionPermission]: + known_permission_ids = extension_api_permission_ids() + normalized_permissions: list[ExtensionPermission] = [] + unknown_ids: list[str] = [] + + for permission in permissions: + if permission.id not in known_permission_ids: + unknown_ids.append(permission.id) + if strict: + continue + normalized_permissions.append(permission.copy()) + + if unknown_ids and strict: + raise ValueError( + f"Extension '{ext_id}' requests unknown permissions: " + + ", ".join(sorted(set(unknown_ids))) + ) + + return normalized_permissions + + +def validate_wasm_extension_permissions( + ext_info: InstallableExtension, + granted_permissions: list[ExtensionPermission] | None, + extension_config: dict[str, Any] | WasmExtensionConfig, +) -> list[ExtensionPermission]: + if isinstance(extension_config, WasmExtensionConfig): + config = extension_config + elif extension_config.get("extension_type") != "wasm": + return [] + else: + config = parse_wasm_extension_config(ext_info.id, extension_config) + + requested_permissions = validate_extension_permissions( + ext_info.id, config.permissions + ) + if not requested_permissions: + return [] + + if granted_permissions is None: + raise ValueError(f"Extension '{ext_info.id}' requires permission approval.") + + granted_permissions = validate_extension_permissions( + ext_info.id, + granted_permissions, + ) + requested_by_id = _permission_index(ext_info.id, requested_permissions, "requested") + granted_by_id = _permission_index(ext_info.id, granted_permissions, "granted") + + extra_granted_ids = sorted(set(granted_by_id) - set(requested_by_id)) + if extra_granted_ids: + raise ValueError( + f"Extension '{ext_info.id}' was granted unrequested permissions: " + + ", ".join(extra_granted_ids) + ) + + effective_permissions: list[ExtensionPermission] = [] + for permission_id, granted_permission in granted_by_id.items(): + requested_permission = requested_by_id[permission_id] + if not _permission_grant_is_subset(requested_permission, granted_permission): + raise ValueError( + f"Extension '{ext_info.id}' was granted broader policies for " + f"permission '{permission_id}'." + ) + effective_permissions.append( + requested_permission.copy(update={"policies": granted_permission.policies}) + ) + + return effective_permissions + + +def _permission_index( + ext_id: str, + permissions: Iterable[ExtensionPermission], + source: str, +) -> dict[str, ExtensionPermission]: + indexed: dict[str, ExtensionPermission] = {} + duplicate_ids: list[str] = [] + + for permission in permissions: + if permission.id in indexed: + duplicate_ids.append(permission.id) + continue + indexed[permission.id] = permission + + if duplicate_ids: + raise ValueError( + f"Extension '{ext_id}' has duplicate {source} permissions: " + + ", ".join(sorted(set(duplicate_ids))) + ) + return indexed + + +def _permission_grant_is_subset( + requested: ExtensionPermission, + granted: ExtensionPermission, +) -> bool: + if requested.id != granted.id: + return False + if requested.id not in _POLICY_AWARE_PERMISSION_IDS: + return True + if requested.id == "http.request": + return _http_request_grant_is_subset(requested.policies, granted.policies) + if requested.id == "extension.api.request": + return _extension_api_grant_is_subset(requested.policies, granted.policies) + if requested.id == "ext.storage.read_public": + return _public_storage_grant_is_subset(requested.policies, granted.policies) + if requested.id == "wallet.create_invoice_public": + return _public_invoice_grant_is_subset(requested.policies, granted.policies) + return False + + +def _policy_list(policies: list[Any] | None) -> list[Any]: + return policies if isinstance(policies, list) else [] + + +def _http_request_grant_is_subset( + requested_policies: list[Any] | None, + granted_policies: list[Any] | None, +) -> bool: + return _http_origins(granted_policies).issubset(_http_origins(requested_policies)) + + +def _http_origins(policies: list[Any] | None) -> set[str]: + origins: set[str] = set() + for policy in _policy_list(policies): + host = policy.get("host") if isinstance(policy, dict) else policy + if not isinstance(host, str) or not host: + continue + try: + origins.add(_request_origin(host)) + except PermissionError: + continue + return origins + + +def _extension_api_grant_is_subset( + requested_policies: list[Any] | None, + granted_policies: list[Any] | None, +) -> bool: + requested_targets = _extension_api_targets(requested_policies) + granted_targets = _extension_api_targets(granted_policies) + for extension_id, granted_access in granted_targets.items(): + requested_access = requested_targets.get(extension_id) + if requested_access is None or not granted_access.issubset(requested_access): + return False + return True + + +def _extension_api_targets(policies: list[Any] | None) -> dict[str, set[str]]: + targets: dict[str, set[str]] = {} + for policy in _policy_list(policies): + extension_id: str | None = None + access: list[Any] = [] + if isinstance(policy, str): + extension_id = policy + access = ["read"] + elif isinstance(policy, dict): + raw_extension_id = policy.get("id") + raw_access = policy.get("access") + if isinstance(raw_extension_id, str) and isinstance(raw_access, list): + extension_id = raw_extension_id + access = raw_access + if not extension_id or extension_id in targets: + continue + clean_access = { + item + for item in access + if isinstance(item, str) and item in {"read", "write"} + } + if clean_access: + targets[extension_id] = clean_access + return targets + + +def _public_storage_grant_is_subset( + requested_policies: list[Any] | None, + granted_policies: list[Any] | None, +) -> bool: + requested_tables = _public_storage_tables(requested_policies) + granted_tables = _public_storage_tables(granted_policies) + for table_name, granted_fields in granted_tables.items(): + requested_fields = requested_tables.get(table_name) + if requested_fields is None or not granted_fields.issubset(requested_fields): + return False + return True + + +def _public_storage_tables(policies: list[Any] | None) -> dict[str, set[str]]: + tables: dict[str, set[str]] = {} + for policy in _policy_list(policies): + if not isinstance(policy, dict): + continue + table_name = policy.get("table_name") + public_fields = policy.get("public_fields") + if ( + not isinstance(table_name, str) + or table_name in tables + or not isinstance(public_fields, list) + ): + continue + fields = {field for field in public_fields if isinstance(field, str) and field} + if fields: + tables[table_name] = fields + return tables + + +def _public_invoice_grant_is_subset( + requested_policies: list[Any] | None, + granted_policies: list[Any] | None, +) -> bool: + return _public_invoice_sources(granted_policies).issubset( + _public_invoice_sources(requested_policies) + ) + + +def _public_invoice_sources(policies: list[Any] | None) -> set[tuple[str, str]]: + sources: set[tuple[str, str]] = set() + for policy in _policy_list(policies): + if not isinstance(policy, dict): + continue + table = policy.get("table") + wallet_field = policy.get("wallet_field") + if isinstance(table, str) and table and isinstance(wallet_field, str): + sources.add((table, wallet_field)) + return sources diff --git a/lnbits/core/wasm_ext/api/registry.py b/lnbits/core/wasm_ext/api/registry.py new file mode 100644 index 000000000..dc4962b7c --- /dev/null +++ b/lnbits/core/wasm_ext/api/registry.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import inspect +from collections.abc import Awaitable, Callable +from functools import wraps +from typing import Any, TypeVar, cast, get_type_hints + +from pydantic import BaseModel + +from .models import ExtensionAPIMethod, ExtensionAPIMethodExport + +_EXTENSION_API_METHOD_ATTR = "__lnbits_extension_api_method__" +_EXTENSION_RUNTIME_PERMISSION_IDS = { + "ui.camera.scan_qr", + "wallet.pay_invoice", + "wallet.pay_invoice_background", + "wallet.payments.watch", +} +_RequestModel = TypeVar("_RequestModel", bound=BaseModel) +_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel) + + +def extension_api_method( + *, + method_id: str, + namespace: str, + name: str, + host_name: str, + sdk_name: str, + description: str, + host_interface: str = "host", + required_permission: str | None = None, + require_auth: bool = True, +) -> Callable[ + [Callable[[Any, _RequestModel], Awaitable[_ResponseModel]]], + Callable[[Any, _RequestModel], Awaitable[_ResponseModel]], +]: + export = ExtensionAPIMethodExport( + method_id=method_id, + namespace=namespace, + name=name, + host_interface=host_interface, + host_name=host_name, + sdk_name=sdk_name, + description=description, + required_permission=required_permission, + require_auth=require_auth, + ) + + def decorator( + function: Callable[[Any, _RequestModel], Awaitable[_ResponseModel]], + ) -> Callable[[Any, _RequestModel], Awaitable[_ResponseModel]]: + @wraps(function) + async def wrapper(self: Any, request: _RequestModel) -> _ResponseModel: + api = getattr(self, "api", self) + if require_auth and not api.has_authenticated_context(): + raise PermissionError( + f"Extension API method '{method_id}' requires authentication." + ) + api.require_permission(required_permission) + return await function(self, request) + + setattr(wrapper, _EXTENSION_API_METHOD_ATTR, export) + return wrapper + + return decorator + + +def list_extension_api_methods( + api_cls: type[Any] | None = None, +) -> list[ExtensionAPIMethod]: + api_cls = _default_api_cls(api_cls) + methods: list[ExtensionAPIMethod] = [] + + for prefix, method_cls in _extension_api_method_sources(api_cls): + for python_name, function in inspect.getmembers(method_cls, inspect.isfunction): + export = getattr(function, _EXTENSION_API_METHOD_ATTR, None) + if not export: + continue + + request_model, response_model = _get_method_models(function) + methods.append( + ExtensionAPIMethod( + method_id=export.method_id, + namespace=export.namespace, + name=export.name, + python_name=f"{prefix}.{python_name}" if prefix else python_name, + host_interface=export.host_interface, + host_name=export.host_name, + sdk_name=export.sdk_name, + description=export.description, + request_model=request_model, + response_model=response_model, + required_permission=export.required_permission, + require_auth=export.require_auth, + ) + ) + + return sorted(methods, key=lambda method: method.method_id) + + +def extension_api_permission_ids(api_cls: type[Any] | None = None) -> set[str]: + permissions = { + method.required_permission + for method in list_extension_api_methods(api_cls) + if method.required_permission + } + permissions.update(_EXTENSION_RUNTIME_PERMISSION_IDS) + return permissions + + +def get_extension_api_method( + method_id: str, + api_cls: type[Any] | None = None, +) -> ExtensionAPIMethod: + for method in list_extension_api_methods(api_cls): + if method.method_id == method_id: + return method + raise KeyError(f"Unknown extension API method '{method_id}'.") + + +def extension_api_contract(api_cls: type[Any] | None = None) -> dict[str, object]: + return { + "version": 1, + "methods": [ + { + "id": method.method_id, + "namespace": method.namespace, + "name": method.name, + "python_name": method.python_name, + "host_interface": method.host_interface, + "host_name": method.host_name, + "sdk_name": method.sdk_name, + "sdk_qualified_name": method.sdk_qualified_name, + "description": method.description, + "required_permission": method.required_permission, + "require_auth": method.require_auth, + "request_schema": method.request_model.schema( + ref_template="#/definitions/{model}" + ), + "response_schema": method.response_model.schema( + ref_template="#/definitions/{model}" + ), + } + for method in list_extension_api_methods(api_cls) + ], + } + + +def _default_api_cls(api_cls: type[Any] | None) -> type[Any]: + if api_cls is not None: + return api_cls + + from .host import ExtensionHostAPI + + return ExtensionHostAPI + + +def _extension_api_method_sources( + api_cls: type[Any], +) -> list[tuple[str, type[Any]]]: + sources: list[tuple[str, type[Any]]] = [("", api_cls)] + + from .host import ExtensionHostAPI + + if issubclass(api_cls, ExtensionHostAPI): + from .utils import extension_api_utils_method_classes + + sources.extend(extension_api_utils_method_classes().items()) + return sources + + +def _get_method_models( + function: Callable[..., object], +) -> tuple[type[BaseModel], type[BaseModel]]: + signature = inspect.signature(function) + request_parameters = [ + parameter + for parameter in signature.parameters.values() + if parameter.name != "self" + ] + if len(request_parameters) != 1: + raise TypeError( + f"Extension API method '{function.__name__}' must accept one request model." + ) + + hints = get_type_hints(function) + request_model = hints.get(request_parameters[0].name) + response_model = hints.get("return") + + if not _is_pydantic_model(request_model): + raise TypeError( + f"Extension API method '{function.__name__}' request must be a BaseModel." + ) + if not _is_pydantic_model(response_model): + raise TypeError( + f"Extension API method '{function.__name__}' response must be a BaseModel." + ) + + return cast(type[BaseModel], request_model), cast(type[BaseModel], response_model) + + +def _is_pydantic_model(value: object) -> bool: + return isinstance(value, type) and issubclass(value, BaseModel) diff --git a/lnbits/core/wasm_ext/api/runtime.py b/lnbits/core/wasm_ext/api/runtime.py new file mode 100644 index 000000000..e4a62a666 --- /dev/null +++ b/lnbits/core/wasm_ext/api/runtime.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import inspect +import re +from collections.abc import Awaitable, Callable, Mapping +from typing import Any + +from pydantic import BaseModel + +from .host import ExtensionHostAPI +from .models import ExtensionAPIMethod +from .registry import list_extension_api_methods + +HostImport = Callable[..., Awaitable[dict[str, Any]]] + + +class ExtensionAPIHost: + def __init__( + self, + api: ExtensionHostAPI, + *, + api_cls: type[ExtensionHostAPI] = ExtensionHostAPI, + ) -> None: + self.api = api + self.methods = list_extension_api_methods(api_cls) + self._methods_by_host_name = self._index_methods(self.methods) + + async def invoke( + self, + host_name: str, + payload: Mapping[str, Any] | BaseModel | None = None, + ) -> dict[str, Any]: + method = self._require_method(host_name) + from lnbits.core.services.extensions import record_wasm_invocation_host_call + + record_wasm_invocation_host_call(self.api.invocation_id, method.method_id) + request = self._request_model(method, payload) + handler = _resolve_attr_path(self.api, method.python_name) + response = handler(request) + if inspect.isawaitable(response): + response = await response + return self._response_payload(method, response) + + def imports(self) -> dict[str, HostImport]: + return self.imports_for_interface("host") + + def import_object(self) -> dict[str, dict[str, HostImport]]: + interfaces = sorted({method.host_interface for method in self.methods}) + return { + f"lnbits:extension/{interface}": self.imports_for_interface(interface) + for interface in interfaces + } + + def imports_for_interface(self, host_interface: str) -> dict[str, HostImport]: + return { + _snake_to_camel(method.host_name): self._make_import(method) + for method in self.methods + if method.host_interface == host_interface + } + + def _make_import(self, method: ExtensionAPIMethod) -> HostImport: + async def host_import( + payload: Mapping[str, Any] | BaseModel | None = None, + ) -> dict[str, Any]: + return await self.invoke(method.method_id, payload) + + return host_import + + def _require_method(self, host_name: str) -> ExtensionAPIMethod: + method = self._methods_by_host_name.get(host_name) + if not method: + raise KeyError(f"Unknown extension host function '{host_name}'.") + return method + + @staticmethod + def _index_methods( + methods: list[ExtensionAPIMethod], + ) -> dict[str, ExtensionAPIMethod]: + index: dict[str, ExtensionAPIMethod] = {} + for method in methods: + for host_name in { + method.method_id, + f"{method.host_interface}:{method.host_name}", + method.host_name, + _snake_to_camel(method.host_name), + method.host_name.replace("_", "-"), + }: + index[host_name] = method + return index + + @staticmethod + def _request_model( + method: ExtensionAPIMethod, + payload: Mapping[str, Any] | BaseModel | None, + ) -> BaseModel: + if isinstance(payload, method.request_model): + return payload + if isinstance(payload, BaseModel): + payload = payload.dict() + if payload is None: + payload = {} + if not isinstance(payload, Mapping): + raise TypeError( + f"Host function '{method.host_name}' expects an object payload." + ) + data = {_to_snake(key): value for key, value in payload.items()} + if isinstance(data.get("extra"), list): + data["extra"] = dict(data["extra"]) + if isinstance(data.get("headers"), list): + data["headers"] = dict(data["headers"]) + return method.request_model.parse_obj(data) + + @staticmethod + def _response_payload( + method: ExtensionAPIMethod, + response: Any, + ) -> dict[str, Any]: + if not isinstance(response, method.response_model): + response = method.response_model.parse_obj(response) + payload = response.dict() + if method.method_id in {"http.request", "extension.api.request"} and isinstance( + payload.get("headers"), Mapping + ): + payload["headers"] = list(payload["headers"].items()) + return {_snake_to_camel(key): value for key, value in payload.items()} + + +def _snake_to_camel(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(part.capitalize() for part in tail) + + +def _to_snake(value: str) -> str: + value = value.replace("-", "_") + return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value).lower() + + +def _resolve_attr_path(value: Any, path: str) -> Any: + for part in path.split("."): + value = getattr(value, part) + return value diff --git a/lnbits/core/wasm_ext/api/utils.py b/lnbits/core/wasm_ext/api/utils.py new file mode 100644 index 000000000..bb664b168 --- /dev/null +++ b/lnbits/core/wasm_ext/api/utils.py @@ -0,0 +1,475 @@ +from __future__ import annotations + +import time +from collections.abc import Iterable +from datetime import datetime +from typing import Any + +from lnurl import LnurlErrorResponse, LnurlPayResponse, LnurlResponseException +from lnurl import handle as lnurl_handle + +from lnbits import bolt11 +from lnbits.settings import settings +from lnbits.utils.crypto import random_secret_and_hash, verify_preimage +from lnbits.utils.exchange_rates import ( + allowed_currencies, + fiat_amount_as_satoshis, + get_fiat_rate_and_price_satoshis, + satoshis_amount_as_fiat, +) + +from .lnurl import ( + lnurl_pay_response_int, + lnurl_pay_response_metadata_json, + lnurl_pay_response_text, + normalize_lnurl, +) +from .models import ( + Bolt11Request, + CurrencyConvertRequest, + CurrencyConvertResponse, + CurrencyListResponse, + CurrencyRateRequest, + CurrencyRateResponse, + DecodeInvoiceResponse, + EmptyRequest, + FiatToSatsRequest, + FiatToSatsResponse, + InvoiceAmountMsatResponse, + InvoiceExpiryResponse, + InvoiceMemoResponse, + InvoicePaymentHashResponse, + LnurlResolveRequest, + LnurlResolveResponse, + RandomSecretAndHashRequest, + RandomSecretAndHashResponse, + SatsToFiatRequest, + SatsToFiatResponse, + ServerHealthResponse, + ValidateInvoiceResponse, + VerifyPreimageRequest, + VerifyPreimageResponse, +) +from .registry import extension_api_method + + +class ExtensionAPIUtils: + def __init__( + self, + extension_id: str, + permissions: Iterable[str], + *, + authenticated: bool = False, + ) -> None: + permission_set = set(permissions) + self.currencies = ExtensionCurrencyUtils( + extension_id, permission_set, authenticated=authenticated + ) + self.server = ExtensionServerUtils( + extension_id, permission_set, authenticated=authenticated + ) + self.lightning = ExtensionLightningUtils( + extension_id, permission_set, authenticated=authenticated + ) + self.lnurl = ExtensionLnurlUtils( + extension_id, permission_set, authenticated=authenticated + ) + + +class _ExtensionAPIUtilsGroup: + def __init__( + self, + extension_id: str, + permissions: Iterable[str], + *, + authenticated: bool = False, + ) -> None: + self.extension_id = extension_id + self.permissions = set(permissions) + self.authenticated = authenticated + + def require_permission(self, permission: str | None) -> None: + if permission and permission not in self.permissions: + raise PermissionError( + f"Extension '{self.extension_id}' is missing permission '{permission}'." + ) + + def has_authenticated_context(self) -> bool: + return self.authenticated + + +class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup): + @extension_api_method( + method_id="utils.currencies.list", + namespace="utils.currencies", + name="List currencies", + host_interface="utils-currencies", + host_name="list_currencies", + sdk_name="list", + description="List currencies supported by LNbits exchange-rate conversion.", + required_permission="utils.basic", + require_auth=False, + ) + async def list(self, request: EmptyRequest) -> CurrencyListResponse: + + return CurrencyListResponse(currencies=allowed_currencies()) + + @extension_api_method( + method_id="utils.currencies.rate", + namespace="utils.currencies", + name="Get currency rate", + host_interface="utils-currencies", + host_name="rate", + sdk_name="rate", + description="Get sats-per-fiat and BTC price for a currency.", + required_permission="utils.basic", + require_auth=False, + ) + async def rate(self, request: CurrencyRateRequest) -> CurrencyRateResponse: + + rate, price = await get_fiat_rate_and_price_satoshis(request.currency) + return CurrencyRateResponse(rate=rate, price=price) + + @extension_api_method( + method_id="utils.currencies.convert", + namespace="utils.currencies", + name="Convert currency amount", + host_interface="utils-currencies", + host_name="convert", + sdk_name="convert", + description="Convert between sats, BTC, and supported fiat currencies.", + required_permission="utils.basic", + require_auth=False, + ) + async def convert(self, request: CurrencyConvertRequest) -> CurrencyConvertResponse: + + from_currency = request.from_currency + if from_currency == "sats": + from_currency = "sat" + + amounts: list[tuple[str, float]] = [] + if from_currency == "sat": + sats = int(request.amount) + amounts.append(("BTC", sats / 100_000_000)) + amounts.append(("sats", sats)) + for currency in request.to.split(","): + currency = currency.strip() + if currency: + amounts.append( + ( + currency.upper(), + await satoshis_amount_as_fiat(sats, currency), + ) + ) + else: + sats = await fiat_amount_as_satoshis(request.amount, from_currency) + amounts.append((from_currency.upper(), request.amount)) + amounts.append(("sats", sats)) + amounts.append(("BTC", sats / 100_000_000)) + return CurrencyConvertResponse(amounts=amounts) + + @extension_api_method( + method_id="utils.currencies.fiat_to_sats", + namespace="utils.currencies", + name="Convert fiat to sats", + host_interface="utils-currencies", + host_name="fiat_to_sats", + sdk_name="fiatToSats", + description="Convert a fiat amount to sats.", + required_permission="utils.basic", + require_auth=False, + ) + async def fiat_to_sats(self, request: FiatToSatsRequest) -> FiatToSatsResponse: + + return FiatToSatsResponse( + amount_sat=await fiat_amount_as_satoshis( + request.amount, + request.currency, + ) + ) + + @extension_api_method( + method_id="utils.currencies.sats_to_fiat", + namespace="utils.currencies", + name="Convert sats to fiat", + host_interface="utils-currencies", + host_name="sats_to_fiat", + sdk_name="satsToFiat", + description="Convert a sats amount to fiat.", + required_permission="utils.basic", + require_auth=False, + ) + async def sats_to_fiat(self, request: SatsToFiatRequest) -> SatsToFiatResponse: + + return SatsToFiatResponse( + amount=await satoshis_amount_as_fiat(request.amount, request.currency) + ) + + +class ExtensionServerUtils(_ExtensionAPIUtilsGroup): + @extension_api_method( + method_id="utils.server.health", + namespace="utils.server", + name="Server health", + host_interface="utils-server", + host_name="health", + sdk_name="health", + description="Return basic public LNbits server health data.", + required_permission="utils.basic", + require_auth=False, + ) + async def health(self, request: EmptyRequest) -> ServerHealthResponse: + + return ServerHealthResponse( + server_time=int(time.time()), + up_time=settings.lnbits_server_up_time, + ) + + +class ExtensionLnurlUtils(_ExtensionAPIUtilsGroup): + @extension_api_method( + method_id="utils.lnurl.resolve", + namespace="utils.lnurl", + name="Resolve LNURL-pay", + host_interface="utils-lnurl", + host_name="resolve", + sdk_name="resolve", + description="Resolve a Lightning Address or LNURL-pay request.", + required_permission="wallet.pay_invoice", + require_auth=True, + ) + async def resolve(self, request: LnurlResolveRequest) -> LnurlResolveResponse: + normalized_lnurl = normalize_lnurl(request.lnurl) + response = await lnurl_handle( + normalized_lnurl, + user_agent=settings.user_agent, + timeout=5, + ) + if isinstance(response, LnurlErrorResponse): + raise LnurlResponseException(response.reason) + if not isinstance(response, LnurlPayResponse): + raise LnurlResponseException( + "Invalid LNURL response. Expected LnurlPayResponse." + ) + + min_sendable_msat = lnurl_pay_response_int( + response, "min_sendable", "minSendable" + ) + max_sendable_msat = lnurl_pay_response_int( + response, "max_sendable", "maxSendable" + ) + image = getattr(response, "image", None) + return LnurlResolveResponse( + lnurl=normalized_lnurl, + domain=getattr(response, "domain", None), + description=lnurl_pay_response_text(response), + min_sendable_msat=min_sendable_msat, + max_sendable_msat=max_sendable_msat, + comment_allowed=lnurl_pay_response_int( + response, "comment_allowed", "commentAllowed" + ), + fixed=bool( + getattr( + response, + "fixed", + min_sendable_msat == max_sendable_msat, + ) + ), + image=str(image) if image is not None else None, + metadata_json=lnurl_pay_response_metadata_json(response), + ) + + +class ExtensionLightningUtils(_ExtensionAPIUtilsGroup): + @extension_api_method( + method_id="utils.lightning.decode_invoice", + namespace="utils.lightning", + name="Decode Lightning invoice", + host_interface="utils-lightning", + host_name="decode_invoice", + sdk_name="decodeInvoice", + description="Decode a BOLT11 Lightning invoice.", + required_permission="utils.basic", + require_auth=False, + ) + async def decode_invoice(self, request: Bolt11Request) -> DecodeInvoiceResponse: + invoice = _decode_bolt11(request.bolt11) + return _decoded_invoice_response(invoice) + + @extension_api_method( + method_id="utils.lightning.validate_invoice", + namespace="utils.lightning", + name="Validate Lightning invoice", + host_interface="utils-lightning", + host_name="validate_invoice", + sdk_name="validateInvoice", + description="Validate whether a string is a BOLT11 Lightning invoice.", + required_permission="utils.basic", + require_auth=False, + ) + async def validate_invoice(self, request: Bolt11Request) -> ValidateInvoiceResponse: + try: + _decode_bolt11(request.bolt11) + return ValidateInvoiceResponse(valid=True) + except Exception as exc: + return ValidateInvoiceResponse(valid=False, error=str(exc)) + + @extension_api_method( + method_id="utils.lightning.invoice_payment_hash", + namespace="utils.lightning", + name="Get Lightning invoice payment hash", + host_interface="utils-lightning", + host_name="invoice_payment_hash", + sdk_name="invoicePaymentHash", + description="Get the payment hash from a BOLT11 Lightning invoice.", + required_permission="utils.basic", + require_auth=False, + ) + async def invoice_payment_hash( + self, request: Bolt11Request + ) -> InvoicePaymentHashResponse: + return InvoicePaymentHashResponse( + payment_hash=str(_decode_bolt11(request.bolt11).payment_hash) + ) + + @extension_api_method( + method_id="utils.lightning.invoice_amount_msat", + namespace="utils.lightning", + name="Get Lightning invoice amount", + host_interface="utils-lightning", + host_name="invoice_amount_msat", + sdk_name="invoiceAmountMsat", + description="Get the amount in msat from a BOLT11 Lightning invoice.", + required_permission="utils.basic", + require_auth=False, + ) + async def invoice_amount_msat( + self, request: Bolt11Request + ) -> InvoiceAmountMsatResponse: + return InvoiceAmountMsatResponse( + amount_msat=_invoice_amount_msat(_decode_bolt11(request.bolt11)) + ) + + @extension_api_method( + method_id="utils.lightning.invoice_expiry", + namespace="utils.lightning", + name="Get Lightning invoice expiry", + host_interface="utils-lightning", + host_name="invoice_expiry", + sdk_name="invoiceExpiry", + description="Get the expiry timestamp from a BOLT11 Lightning invoice.", + required_permission="utils.basic", + require_auth=False, + ) + async def invoice_expiry(self, request: Bolt11Request) -> InvoiceExpiryResponse: + return InvoiceExpiryResponse( + expires_at=_invoice_expires_at(_decode_bolt11(request.bolt11)) + ) + + @extension_api_method( + method_id="utils.lightning.invoice_memo", + namespace="utils.lightning", + name="Get Lightning invoice memo", + host_interface="utils-lightning", + host_name="invoice_memo", + sdk_name="invoiceMemo", + description="Get the memo from a BOLT11 Lightning invoice.", + required_permission="utils.basic", + require_auth=False, + ) + async def invoice_memo(self, request: Bolt11Request) -> InvoiceMemoResponse: + return InvoiceMemoResponse(memo=_invoice_memo(_decode_bolt11(request.bolt11))) + + @extension_api_method( + method_id="utils.lightning.verify_preimage", + namespace="utils.lightning", + name="Verify Lightning preimage", + host_interface="utils-lightning", + host_name="verify_preimage", + sdk_name="verifyPreimage", + description="Verify that a preimage matches a payment hash.", + required_permission="utils.basic", + require_auth=False, + ) + async def verify_preimage( + self, request: VerifyPreimageRequest + ) -> VerifyPreimageResponse: + + return VerifyPreimageResponse( + valid=verify_preimage(request.preimage, request.payment_hash) + ) + + @extension_api_method( + method_id="utils.lightning.random_secret_and_hash", + namespace="utils.lightning", + name="Random Lightning secret and hash", + host_interface="utils-lightning", + host_name="random_secret_and_hash", + sdk_name="randomSecretAndHash", + description="Create a random secret and matching SHA256 hash.", + required_permission="utils.basic", + require_auth=False, + ) + async def random_secret_and_hash( + self, request: RandomSecretAndHashRequest + ) -> RandomSecretAndHashResponse: + + secret, payment_hash = random_secret_and_hash(request.length) + return RandomSecretAndHashResponse(secret=secret, hash=payment_hash) + + +def extension_api_utils_method_classes() -> dict[str, type[_ExtensionAPIUtilsGroup]]: + return { + "utils.currencies": ExtensionCurrencyUtils, + "utils.server": ExtensionServerUtils, + "utils.lnurl": ExtensionLnurlUtils, + "utils.lightning": ExtensionLightningUtils, + } + + +def _decode_bolt11(payment_request: str) -> Any: + + return bolt11.decode(payment_request) + + +def _decoded_invoice_response(invoice: Any) -> DecodeInvoiceResponse: + return DecodeInvoiceResponse( + payment_hash=str(getattr(invoice, "payment_hash", "")) or None, + amount_msat=_invoice_amount_msat(invoice), + expiry=_invoice_expiry(invoice), + expires_at=_invoice_expires_at(invoice), + memo=_invoice_memo(invoice), + ) + + +def _invoice_amount_msat(invoice: Any) -> int | None: + amount_msat = getattr(invoice, "amount_msat", None) + if amount_msat is None: + return None + return int(amount_msat) + + +def _invoice_expiry(invoice: Any) -> int | None: + expiry = getattr(invoice, "expiry", None) + if expiry is None: + return None + return int(expiry) + + +def _invoice_expires_at(invoice: Any) -> int | None: + expiry_date = getattr(invoice, "expiry_date", None) + if isinstance(expiry_date, datetime): + return int(expiry_date.timestamp()) + + date = getattr(invoice, "date", None) + expiry = getattr(invoice, "expiry", None) + if isinstance(date, datetime) and expiry is not None: + return int(date.timestamp() + int(expiry)) + if isinstance(date, (int, float)) and expiry is not None: + return int(date + int(expiry)) + return None + + +def _invoice_memo(invoice: Any) -> str | None: + memo = getattr(invoice, "description", None) + return str(memo) if memo is not None else None diff --git a/lnbits/core/wasm_ext/client/__init__.py b/lnbits/core/wasm_ext/client/__init__.py new file mode 100644 index 000000000..42d836d84 --- /dev/null +++ b/lnbits/core/wasm_ext/client/__init__.py @@ -0,0 +1,4 @@ +from .extensions import send_extension_api_request +from .http import send_extension_http_request + +__all__ = ["send_extension_api_request", "send_extension_http_request"] diff --git a/lnbits/core/wasm_ext/client/extensions.py b/lnbits/core/wasm_ext/client/extensions.py new file mode 100644 index 000000000..b40484f14 --- /dev/null +++ b/lnbits/core/wasm_ext/client/extensions.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import posixpath +import re +from typing import Any +from urllib.parse import unquote, urlsplit, urlunsplit + +import httpx + +from lnbits.core.crud.extensions import ( + get_installed_extension, + get_user_active_extensions_ids, +) +from lnbits.settings import settings + +from ..api.models import ExtensionApiRequest, HttpResponse + +EXTENSION_API_TIMEOUT_SECONDS = 10.0 +EXTENSION_API_MAX_RESPONSE_BYTES = 262_144 + +_READ_METHODS = {"GET", "HEAD"} +_WRITE_METHODS = {"DELETE", "PATCH", "POST", "PUT"} +_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") +_FORBIDDEN_RESPONSE_HEADERS = { + "connection", + "content-length", + "set-cookie", + "transfer-encoding", +} + + +async def send_extension_api_request( + caller_extension_id: str, + policies: list[Any], + user_id: str | None, + access_token: str | None, + request: ExtensionApiRequest, + *, + timeout_ms: int | None = None, + max_response_bytes: int | None = None, +) -> HttpResponse: + if not user_id: + raise PermissionError("Extension API requests require authentication.") + if not access_token: + raise PermissionError("Extension API requests require an account access token.") + + target_extension_id = _target_extension_id(request.extension_id) + access = _target_extension_access(policies, target_extension_id) + _require_method_access(caller_extension_id, target_extension_id, access, request) + await _require_enabled_extension(target_extension_id, user_id) + + path = _extension_api_path(request.path) + body = request.body.encode() if request.body is not None else b"" + if len(body) > 65_536: + raise ValueError("Extension API request body is too large.") + + url = f"http://{settings.host}:{settings.port}/{target_extension_id}{path}" + try: + async with httpx.AsyncClient( + follow_redirects=False, + timeout=_timeout_seconds(timeout_ms, EXTENSION_API_TIMEOUT_SECONDS), + trust_env=False, + ) as client: + async with client.stream( + request.method, + url, + headers={"Authorization": f"Bearer {access_token}"}, + content=body, + ) as response: + response_body = await _read_limited_response( + response, + max_response_bytes=max_response_bytes, + ) + return HttpResponse( + status_code=response.status_code, + headers=_response_headers(dict(response.headers)), + body=response_body.decode(response.encoding or "utf-8", "replace"), + ) + except httpx.RequestError as exc: + raise ValueError("Extension API request failed.") from exc + + +def _target_extension_id(extension_id: str) -> str: + target = extension_id.strip() + if not target or not _EXTENSION_ID_RE.match(target): + raise PermissionError("Extension API request has an invalid target extension.") + return target + + +def _target_extension_access(policies: list[Any], target_extension_id: str) -> set[str]: + if not isinstance(policies, list) or not policies: + raise PermissionError( + "Extension API requests require a non-empty extensions policy." + ) + + for extension in policies: + if isinstance(extension, str): + extension_id = extension + access = ["read"] + elif isinstance(extension, dict): + raw_extension_id = extension.get("id") + raw_access = extension.get("access") + if not isinstance(raw_extension_id, str): + continue + if not isinstance(raw_access, list): + raise PermissionError( + f"Extension API target '{target_extension_id}' " + "has no access policy." + ) + extension_id = raw_extension_id + access = raw_access + else: + continue + + if extension_id != target_extension_id: + continue + clean_access = { + item + for item in access + if isinstance(item, str) and item in {"read", "write"} + } + if clean_access: + return clean_access + break + + raise PermissionError( + f"Extension API target '{target_extension_id}' is not allowed." + ) + + +def _require_method_access( + caller_extension_id: str, + target_extension_id: str, + access: set[str], + request: ExtensionApiRequest, +) -> None: + if request.method in _READ_METHODS: + required_access = "read" + elif request.method in _WRITE_METHODS: + required_access = "write" + else: + raise PermissionError("Extension API request method is not allowed.") + + if required_access not in access: + raise PermissionError( + f"Extension '{caller_extension_id}' cannot {required_access} " + f"extension '{target_extension_id}'." + ) + + +async def _require_enabled_extension(target_extension_id: str, user_id: str) -> None: + extension = await get_installed_extension(target_extension_id) + if not extension or not extension.active: + raise PermissionError( + f"Target extension '{target_extension_id}' is not installed or enabled." + ) + + active_extensions = await get_user_active_extensions_ids(user_id) + if target_extension_id not in active_extensions: + raise PermissionError( + f"Target extension '{target_extension_id}' is not active for this user." + ) + + +def _extension_api_path(path: str) -> str: + parts = urlsplit(path) + if parts.scheme or parts.netloc: + raise PermissionError("Extension API request path must be relative.") + if parts.fragment: + raise PermissionError("Extension API request path cannot include a fragment.") + if not parts.path.startswith("/api/"): + raise PermissionError("Extension API request path must start with '/api/'.") + + decoded_path = unquote(parts.path) + path_parts = decoded_path.split("/") + if any(part == ".." for part in path_parts): + raise PermissionError("Extension API request path cannot traverse directories.") + + normalized = posixpath.normpath(decoded_path) + if normalized != decoded_path.rstrip("/") or not normalized.startswith("/api/"): + raise PermissionError("Extension API request path is invalid.") + + return urlunsplit(("", "", parts.path, parts.query, "")) + + +async def _read_limited_response( + response: httpx.Response, + *, + max_response_bytes: int | None = None, +) -> bytes: + limit = ( + EXTENSION_API_MAX_RESPONSE_BYTES + if max_response_bytes is None + else max_response_bytes + ) + chunks: list[bytes] = [] + size = 0 + async for chunk in response.aiter_bytes(): + size += len(chunk) + if limit > 0 and size > limit: + raise ValueError("Extension API response is too large.") + chunks.append(chunk) + return b"".join(chunks) + + +def _timeout_seconds(timeout_ms: int | None, default: float) -> float | None: + if timeout_ms is None: + return default + if timeout_ms <= 0: + return None + return timeout_ms / 1000 + + +def _response_headers(headers: dict[str, str]) -> dict[str, str]: + return { + key: value + for key, value in headers.items() + if key.lower() not in _FORBIDDEN_RESPONSE_HEADERS + } diff --git a/lnbits/core/wasm_ext/client/http.py b/lnbits/core/wasm_ext/client/http.py new file mode 100644 index 000000000..e3876d9b6 --- /dev/null +++ b/lnbits/core/wasm_ext/client/http.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import ipaddress +import socket +from typing import Any +from urllib.parse import urlparse + +import httpx + +from ..api.models import HttpRequest, HttpResponse + +HTTP_REQUEST_TIMEOUT_SECONDS = 10.0 +HTTP_MAX_RESPONSE_BYTES = 262_144 + +_FORBIDDEN_REQUEST_HEADERS = { + "connection", + "content-length", + "cookie", + "host", + "proxy-authorization", + "transfer-encoding", +} +_FORBIDDEN_RESPONSE_HEADERS = { + "connection", + "content-length", + "set-cookie", + "transfer-encoding", +} + + +async def send_extension_http_request( + extension_id: str, + policies: list[Any], + request: HttpRequest, + *, + timeout_ms: int | None = None, + max_response_bytes: int | None = None, +) -> HttpResponse: + allowed_origins = _allowed_origins(policies) + origin = _request_origin(request.url) + if origin not in allowed_origins: + raise PermissionError( + f"Extension '{extension_id}' is not allowed to request '{origin}'." + ) + + await _reject_internal_host(request.url) + headers = _request_headers(request.headers) + body = request.body.encode() if request.body is not None else b"" + if len(body) > 65_536: + raise ValueError("HTTP request body is too large.") + + try: + async with httpx.AsyncClient( + follow_redirects=False, + timeout=_timeout_seconds(timeout_ms, HTTP_REQUEST_TIMEOUT_SECONDS), + trust_env=False, + ) as client: + async with client.stream( + request.method, + request.url, + headers=headers, + content=body, + ) as response: + response_body = await _read_limited_response( + response, + max_response_bytes=max_response_bytes, + ) + return HttpResponse( + status_code=response.status_code, + headers=_response_headers(dict(response.headers)), + body=response_body.decode(response.encoding or "utf-8", "replace"), + ) + except httpx.RequestError as exc: + raise ValueError("HTTP request failed.") from exc + + +def _allowed_origins(policies: list[Any]) -> set[str]: + if not isinstance(policies, list) or not policies: + raise PermissionError("HTTP requests require a non-empty hosts policy.") + + origins: set[str] = set() + for policy in policies: + host = policy.get("host") if isinstance(policy, dict) else policy + if not isinstance(host, str) or not host: + continue + origins.add(_request_origin(host)) + if not origins: + raise PermissionError("HTTP requests require at least one valid host.") + return origins + + +def _request_origin(url: str) -> str: + parsed = urlparse(url) + if parsed.scheme != "https": + raise PermissionError("HTTP requests require https URLs.") + if parsed.username or parsed.password: + raise PermissionError("HTTP requests cannot include credentials in URLs.") + if not parsed.hostname: + raise PermissionError("HTTP requests require a hostname.") + + hostname = parsed.hostname.lower() + port = _url_port(parsed) + if port is None or port == 443: + return f"https://{hostname}" + return f"https://{hostname}:{port}" + + +def _url_port(parsed: Any) -> int | None: + try: + return parsed.port + except ValueError as exc: + raise PermissionError("HTTP request URL has an invalid port.") from exc + + +async def _reject_internal_host(url: str) -> None: + parsed = urlparse(url) + hostname = parsed.hostname + if not hostname: + raise PermissionError("HTTP requests require a hostname.") + if hostname == "localhost" or hostname.endswith(".localhost"): + raise PermissionError("HTTP requests cannot target localhost.") + + try: + address = ipaddress.ip_address(hostname) + _reject_internal_address(address) + return + except ValueError: + pass + + for address in await _resolve_host(hostname): + _reject_internal_address(address) + + +async def _resolve_host( + hostname: str, +) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]: + import asyncio + + def resolve() -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]: + try: + infos = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) + except socket.gaierror as exc: + raise PermissionError("HTTP request host could not be resolved.") from exc + + addresses: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = [] + for info in infos: + sockaddr = info[4] + addresses.append(ipaddress.ip_address(sockaddr[0])) + return addresses + + return await asyncio.to_thread(resolve) + + +def _reject_internal_address( + address: ipaddress.IPv4Address | ipaddress.IPv6Address, +) -> None: + if not address.is_global: + raise PermissionError("HTTP requests cannot target internal network addresses.") + + +def _request_headers(headers: dict[str, str]) -> dict[str, str]: + clean: dict[str, str] = {} + for key, value in headers.items(): + header = key.strip() + if not header: + continue + if header.lower() in _FORBIDDEN_REQUEST_HEADERS: + continue + clean[header] = value + return clean + + +async def _read_limited_response( + response: httpx.Response, + *, + max_response_bytes: int | None = None, +) -> bytes: + limit = ( + HTTP_MAX_RESPONSE_BYTES if max_response_bytes is None else max_response_bytes + ) + chunks: list[bytes] = [] + size = 0 + async for chunk in response.aiter_bytes(): + size += len(chunk) + if limit > 0 and size > limit: + raise ValueError("HTTP response is too large.") + chunks.append(chunk) + return b"".join(chunks) + + +def _timeout_seconds(timeout_ms: int | None, default: float) -> float | None: + if timeout_ms is None: + return default + if timeout_ms <= 0: + return None + return timeout_ms / 1000 + + +def _response_headers(headers: dict[str, str]) -> dict[str, str]: + return { + key: value + for key, value in headers.items() + if key.lower() not in _FORBIDDEN_RESPONSE_HEADERS + } diff --git a/lnbits/core/wasm_ext/routes/__init__.py b/lnbits/core/wasm_ext/routes/__init__.py new file mode 100644 index 000000000..a1f650938 --- /dev/null +++ b/lnbits/core/wasm_ext/routes/__init__.py @@ -0,0 +1,3 @@ +from .register import register_wasm_extension + +__all__ = ["register_wasm_extension"] diff --git a/lnbits/core/wasm_ext/routes/api.py b/lnbits/core/wasm_ext/routes/api.py new file mode 100644 index 000000000..715e65fdf --- /dev/null +++ b/lnbits/core/wasm_ext/routes/api.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from typing import Annotated, Any + +from fastapi import Depends, FastAPI, HTTPException, Request + +from lnbits.core.models import Account +from lnbits.core.services.extensions import get_wasm_runtime_limits_for_extension +from lnbits.decorators import check_access_token, check_account_exists +from lnbits.settings import settings + +from ..wasm.config import WasmAPIRouteConfig +from ..wasm.invoke import invoke_wasm_extension_export +from ..wasm.loader import WasmExtension + + +class WasmRequestBodyTooLargeError(ValueError): + pass + + +@dataclass(frozen=True) +class WasmRoutePayload: + data: dict[str, Any] + request_bytes: int | None + + +def register_wasm_extension_api_routes(app: FastAPI, extension: WasmExtension) -> None: + for route_config in extension.config.api_routes: + _add_wasm_extension_api_route(app, extension, route_config) + + +def _add_wasm_extension_api_route( + app: FastAPI, + extension: WasmExtension, + route_config: WasmAPIRouteConfig, +) -> None: + method = _wasm_extension_api_method(extension, route_config.method) + route_path = _wasm_extension_api_path(extension, route_config.path) + export_name = _wasm_extension_api_export(extension, route_config.export) + path_params = route_config.path_params + auth = _wasm_extension_route_auth(extension, route_config.auth) + + if _has_route(app, route_path, method): + return + + async def invoke_wasm_api_request( + request: Request, + account: Account | None = None, + access_token: str | None = None, + ) -> dict[str, Any]: + try: + limits = await get_wasm_runtime_limits_for_extension(extension.id) + payload = await _read_api_payload( + request, + path_params, + max_body_bytes=limits["wasm_runtime_max_request_bytes"], + ) + return await invoke_wasm_extension_export( + extension.id, + export_name, + payload.data, + user=account, + access_token=access_token, + trigger_type="http", + method=request.method, + path=request.url.path, + request_id=request.headers.get("x-request-id"), + request_bytes=payload.request_bytes, + context_data={"origin": _request_origin(request)}, + ) + except WasmRequestBodyTooLargeError as exc: + raise HTTPException(status_code=413, detail=str(exc)) from exc + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + async def invoke_private_wasm_extension_export( + request: Request, + access_token: Annotated[str | None, Depends(check_access_token)], + account: Account = Depends(check_account_exists), + ) -> dict[str, Any]: + return await invoke_wasm_api_request(request, account, access_token) + + async def invoke_public_wasm_extension_export(request: Request) -> dict[str, Any]: + return await invoke_wasm_api_request(request) + + app.add_api_route( + route_path, + ( + invoke_public_wasm_extension_export + if auth == "public" + else invoke_private_wasm_extension_export + ), + methods=[method], + name=f"{extension.id}:{method}:{route_path}", + include_in_schema=False, + ) + + +async def _read_api_payload( + request: Request, + path_params: dict[str, str], + *, + max_body_bytes: int, +) -> WasmRoutePayload: + payload = _read_api_path_params(request, path_params) + payload.update(_read_api_query_params(request)) + request_bytes: int | None = None + if request.method in {"POST", "PUT", "PATCH"}: + body, request_bytes = await _read_json_object_with_size( + request, + max_body_bytes=max_body_bytes, + ) + payload.update(body) + return WasmRoutePayload(payload, request_bytes) + + +async def _read_json_object( + request: Request, + *, + max_body_bytes: int | None = None, +) -> dict[str, Any]: + body, _ = await _read_json_object_with_size( + request, + max_body_bytes=( + settings.wasm_runtime_max_request_bytes + if max_body_bytes is None + else max_body_bytes + ), + ) + return body + + +async def _read_json_object_with_size( + request: Request, + *, + max_body_bytes: int, +) -> tuple[dict[str, Any], int]: + body = await _read_limited_body(request, max_body_bytes=max_body_bytes) + if not body: + return {}, 0 + value = json.loads(body) + if not isinstance(value, dict): + raise TypeError("WASM extension API payload must be a JSON object.") + return value, len(body) + + +async def _read_limited_body(request: Request, *, max_body_bytes: int) -> bytes: + content_length = _request_content_length(request) + if _wasm_request_too_large(content_length, max_body_bytes): + raise WasmRequestBodyTooLargeError( + f"WASM extension request is too large: {content_length} bytes." + ) + + chunks: list[bytes] = [] + size = 0 + async for chunk in request.stream(): + if not chunk: + continue + size += len(chunk) + if _wasm_request_too_large(size, max_body_bytes): + raise WasmRequestBodyTooLargeError( + f"WASM extension request is too large: {size} bytes." + ) + chunks.append(chunk) + return b"".join(chunks) + + +def _read_api_path_params( + request: Request, + path_params: dict[str, str], +) -> dict[str, Any]: + payload: dict[str, Any] = {} + for key, value in request.path_params.items(): + target = path_params.get(key) or _snake_to_camel(key) + payload[target] = value + return payload + + +def _read_api_query_params(request: Request) -> dict[str, Any]: + return {_snake_to_camel(key): value for key, value in request.query_params.items()} + + +def _request_content_length(request: Request) -> int | None: + content_length = request.headers.get("content-length") + if content_length and content_length.isdigit(): + return int(content_length) + return None + + +def _wasm_request_too_large(size: int | None, max_body_bytes: int) -> bool: + return size is not None and max_body_bytes > 0 and size > max_body_bytes + + +def _request_origin(request: Request) -> str | None: + origin = request.headers.get("origin") + if not origin: + return None + return origin[:256] + + +def _wasm_extension_api_export(extension: WasmExtension, export_name: Any) -> str: + if not isinstance(export_name, str) or not export_name: + raise ValueError(f"Invalid API export for WASM extension '{extension.id}'.") + + for export in extension.exports: + if export.name != export_name: + continue + if export.visibility in {"public", "authenticated"}: + return export_name + raise PermissionError(f"WASM export '{export_name}' is not callable over HTTP.") + raise KeyError(f"WASM extension '{extension.id}' has no export '{export_name}'.") + + +def _wasm_extension_api_method(extension: WasmExtension, method: Any) -> str: + if not isinstance(method, str): + raise ValueError(f"Invalid API method for WASM extension '{extension.id}'.") + method = method.upper() + if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}: + raise ValueError(f"Unsupported API method for WASM extension '{extension.id}'.") + return method + + +def _wasm_extension_api_path(extension: WasmExtension, path: Any) -> str: + if not isinstance(path, str) or not path.startswith("/"): + raise ValueError(f"Invalid API path for WASM extension '{extension.id}'.") + if path == "/": + return f"/api/v1/ext/{extension.id}" + return f"/api/v1/ext/{extension.id}{path}" + + +def _wasm_extension_route_auth(extension: WasmExtension, auth: Any) -> str: + if auth in {"public", "user"}: + return auth + raise ValueError(f"Invalid route auth for WASM extension '{extension.id}'.") + + +def _has_route(app: FastAPI, route_path: str, method: str) -> bool: + for route in app.routes: + if getattr(route, "path", None) != route_path: + continue + methods = getattr(route, "methods", set()) or set() + if method in methods: + return True + return False + + +def _snake_to_camel(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(part.capitalize() for part in tail) + + +def _path_template_pattern(path: str) -> str: + pattern = re.sub(r"\\{[^/{}]+\\}", r"[^/]+", re.escape(path)) + return f"^{pattern}$" diff --git a/lnbits/core/wasm_ext/routes/assets.py b/lnbits/core/wasm_ext/routes/assets.py new file mode 100644 index 000000000..cb00db842 --- /dev/null +++ b/lnbits/core/wasm_ext/routes/assets.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse, Response +from fastapi.staticfiles import StaticFiles +from starlette.staticfiles import PathLike as StaticFilesPathLike +from starlette.types import Scope + +from lnbits.settings import settings + +from ..wasm.loader import WasmExtension + +WASM_EXTENSION_CORE_ASSET_PREFIX = "_lnbits" +WASM_EXTENSION_CORE_STATIC_ASSETS = { + "bundle.min.css": ("static/bundle.min.css", "text/css; charset=utf-8"), + "material-icons-v50.woff2": ( + "static/fonts/material-icons-v50.woff2", + "font/woff2", + ), + "quasar.css": ("static/vendor/quasar.css", "text/css; charset=utf-8"), + "quasar.umd.prod.js": ( + "static/vendor/quasar.umd.prod.js", + "text/javascript; charset=utf-8", + ), + "qrcode.vue.browser.js": ( + "static/vendor/qrcode.vue.browser.js", + "text/javascript; charset=utf-8", + ), + "vue.global.prod.js": ( + "static/vendor/vue.global.prod.js", + "text/javascript; charset=utf-8", + ), +} +WASM_EXTENSION_GENERATED_CORE_ASSETS = { + "material-icons.css": ( + """ + @font-face { + font-family: 'Material Icons'; + font-style: normal; + font-weight: 400; + src: url('./material-icons-v50.woff2') format('woff2'); + } + """, + "text/css; charset=utf-8", + ) +} +WASM_EXTENSION_STATIC_MIME_TYPES = { + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".ico": "image/x-icon", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "text/javascript; charset=utf-8", + ".png": "image/png", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", +} +WASM_EXTENSION_TEXT_STATIC_EXTENSIONS = {".css", ".js"} +WASM_EXTENSION_HTML_PREFIXES = (b" Response: + if path.startswith(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/"): + return _wasm_extension_core_asset_response(path) + if Path(path).suffix.lower() not in WASM_EXTENSION_STATIC_MIME_TYPES: + raise HTTPException(status_code=404) + return await super().get_response(path, scope) + + def file_response( + self, + full_path: StaticFilesPathLike, + stat_result: os.stat_result, + scope: Scope, + status_code: int = 200, + ) -> Response: + suffix = Path(full_path).suffix.lower() + if suffix in WASM_EXTENSION_TEXT_STATIC_EXTENSIONS: + _reject_html_like_wasm_static_asset(Path(full_path)) + + response = super().file_response(full_path, stat_result, scope, status_code) + response.headers["Content-Type"] = WASM_EXTENSION_STATIC_MIME_TYPES[suffix] + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Cache-Control"] = "no-store" + return response + + +def mount_wasm_extension_static(app: FastAPI, extension: WasmExtension) -> None: + static_path = extension.root_path / "static" + + mount_path = f"/ext-assets/{extension.id}" + if any(getattr(route, "path", None) == mount_path for route in app.routes): + return + + app.mount( + mount_path, + GuardedWasmExtensionStaticFiles(directory=static_path, check_dir=False), + name=f"{extension.id}-static", + ) + + +def _reject_html_like_wasm_static_asset(path: Path) -> None: + with path.open("rb") as asset_file: + prefix = asset_file.read(512).lstrip().lower() + if prefix.startswith(WASM_EXTENSION_HTML_PREFIXES): + raise HTTPException(status_code=404) + + +def _wasm_extension_core_asset_response(path: str) -> Response: + asset_name = path.removeprefix(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/") + if not asset_name or "/" in asset_name or "\\" in asset_name: + raise HTTPException(status_code=404) + + generated_asset = WASM_EXTENSION_GENERATED_CORE_ASSETS.get(asset_name) + if generated_asset: + content, content_type = generated_asset + response = Response(content=content, media_type=content_type) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Cache-Control"] = "no-store" + return response + + asset_config = WASM_EXTENSION_CORE_STATIC_ASSETS.get(asset_name) + if not asset_config: + raise HTTPException(status_code=404) + + relative_path, content_type = asset_config + asset_path = Path(settings.lnbits_path, relative_path) + if not asset_path.is_file(): + raise HTTPException(status_code=404) + + response = FileResponse(asset_path) + response.headers["Content-Type"] = content_type + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Cache-Control"] = "no-store" + return response diff --git a/lnbits/core/wasm_ext/routes/register.py b/lnbits/core/wasm_ext/routes/register.py new file mode 100644 index 000000000..581e3d5fb --- /dev/null +++ b/lnbits/core/wasm_ext/routes/register.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from fastapi import FastAPI +from loguru import logger + +from lnbits.core.db import core_app_extra +from lnbits.settings import settings + +from ..wasm.component import warm_wasm_extension +from ..wasm.loader import WasmExtension, load_wasm_extension +from .api import register_wasm_extension_api_routes +from .assets import mount_wasm_extension_static +from .ui import register_wasm_extension_ui_routes + + +def register_wasm_extension(app: FastAPI, ext_id: str) -> WasmExtension: + loaded = load_wasm_extension(ext_id) + core_app_extra.wasm_extension_registry.require_available(loaded) + + warm_wasm_extension(loaded) + mount_wasm_extension_static(app, loaded) + register_wasm_extension_ui_routes(app, loaded) + register_wasm_extension_api_routes(app, loaded) + + core_app_extra.wasm_extension_registry.register(loaded) + + settings.activate_extension_paths(ext_id, "", []) + logger.info( + f"Loaded WASM extension '{loaded.id}' " + f"({loaded.module_path.stat().st_size} bytes)." + ) + return loaded diff --git a/lnbits/core/wasm_ext/routes/security.py b/lnbits/core/wasm_ext/routes/security.py new file mode 100644 index 000000000..fedafdd3c --- /dev/null +++ b/lnbits/core/wasm_ext/routes/security.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from typing import Any, NoReturn +from uuid import uuid4 + +from fastapi import HTTPException, Request +from loguru import logger + +from lnbits.helpers import template_renderer +from lnbits.utils.cache import cache + +from ..wasm.loader import WasmExtension + +WASM_FRAME_TOKEN_EXPIRY_SECONDS = 60 + + +def wasm_extension_wrapper_response( + request: Request, + extension: WasmExtension, + auth: str, + user_json: str | None, +) -> Any: + public = auth == "public" + response = template_renderer().TemplateResponse( + request, + "wasm_extension.html", + { + "extension": extension, + "public": public, + "user": user_json, + }, + ) + response.headers["Content-Security-Policy"] = "frame-ancestors 'self'" + response.headers["X-Frame-Options"] = "SAMEORIGIN" + return response + + +def wasm_extension_frame_csp(request: Request, extension: WasmExtension) -> str: + origin = str(request.base_url).rstrip("/") + extension_assets = f"{origin}/ext-assets/{extension.id}/" + return ( + "sandbox allow-scripts; " + "default-src 'none'; " + f"script-src {extension_assets}; " + "script-src-attr 'none'; " + f"style-src {extension_assets}; " + "style-src-attr 'none'; " + f"img-src {extension_assets} data:; " + f"font-src {extension_assets}; " + "connect-src 'none'; " + "form-action 'none'; " + "object-src 'none'; " + "base-uri 'none'; " + "frame-src 'none'; " + "worker-src 'none'; " + "media-src 'none'; " + "manifest-src 'none'; " + "frame-ancestors 'self'" + ) + + +def wasm_extension_frame_url( + extension: WasmExtension, frame_path: str, user_id: str | None +) -> str: + token = _create_wasm_extension_frame_token(extension, frame_path, user_id) + return f"{frame_path}?frame_token={token}" + + +def consume_wasm_extension_frame_token( + request: Request, + extension: WasmExtension, + frame_path: str, + user_id: str | None, +) -> None: + token = request.query_params.get("frame_token") + if not token: + _raise_wasm_extension_frame_not_found(extension, frame_path, "missing") + + cache_key = _wasm_extension_frame_token_cache_key(token) + token_data = cache.get(cache_key) + if ( + not isinstance(token_data, dict) + or token_data.get("extension_id") != extension.id + or token_data.get("frame_path") != frame_path + ): + _raise_wasm_extension_frame_not_found( + extension, frame_path, "unknown or expired" + ) + + token_user_id = token_data.get("user_id") + if token_user_id and token_user_id != user_id: + _raise_wasm_extension_frame_not_found(extension, frame_path, "wrong user") + + cache.pop(cache_key) + + +def _create_wasm_extension_frame_token( + extension: WasmExtension, + frame_path: str, + user_id: str | None, +) -> str: + token = uuid4().hex + cache.set( + _wasm_extension_frame_token_cache_key(token), + { + "extension_id": extension.id, + "frame_path": frame_path, + "user_id": user_id, + }, + expiry=WASM_FRAME_TOKEN_EXPIRY_SECONDS, + ) + return token + + +def _wasm_extension_frame_token_cache_key(token: str) -> str: + return f"wasm-frame-token:{token}" + + +def _raise_wasm_extension_frame_not_found( + extension: WasmExtension, + frame_path: str, + reason: str, +) -> NoReturn: + logger.warning( + f"WASM frame token {reason} for extension '{extension.id}' at '{frame_path}'." + ) + raise HTTPException(status_code=404, detail="Not found") diff --git a/lnbits/core/wasm_ext/routes/ui.py b/lnbits/core/wasm_ext/routes/ui.py new file mode 100644 index 000000000..d0d4362f1 --- /dev/null +++ b/lnbits/core/wasm_ext/routes/ui.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Annotated, Any + +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import FileResponse +from pydantic import UUID4 + +from lnbits.core.crud import get_installed_extension, get_user_from_account +from lnbits.core.models import Account +from lnbits.decorators import ( + check_access_token, + check_account_exists, + optional_user_id, +) + +from ..wasm.loader import WasmExtension +from .api import ( + WasmRequestBodyTooLargeError, + _has_route, + _path_template_pattern, + _read_json_object, + _snake_to_camel, + _wasm_extension_api_export, + _wasm_extension_api_method, + _wasm_extension_api_path, + _wasm_extension_route_auth, +) +from .security import ( + consume_wasm_extension_frame_token, + wasm_extension_frame_csp, + wasm_extension_frame_url, + wasm_extension_wrapper_response, +) + + +def register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -> None: + _add_wasm_extension_frame_config_route(app, extension) + + for route_index, route_config in enumerate(extension.config.ui_routes): + route_path = _wasm_extension_ui_route_path(extension, route_config.path) + entrypoint = _wasm_extension_entrypoint(extension, route_config.entrypoint) + frame_path = f"/ext-frame/{extension.id}/{route_index}" + auth = _wasm_extension_route_auth(extension, route_config.auth) + _add_wasm_extension_frame_route(app, extension, frame_path, entrypoint) + _add_wasm_extension_wrapper_route( + app, + extension, + route_path, + auth, + ) + + +def _add_wasm_extension_frame_config_route( + app: FastAPI, + extension: WasmExtension, +) -> None: + route_path = _wasm_extension_frame_config_path(extension) + if _has_route(app, route_path, "POST"): + return + + async def create_wasm_extension_frame_config( + request: Request, + access_token: Annotated[str | None, Depends(check_access_token)], + usr: UUID4 | None = None, + ) -> dict[str, Any]: + try: + body = await _read_json_object(request) + except WasmRequestBodyTooLargeError as exc: + raise HTTPException(status_code=413, detail=str(exc)) from exc + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + ui_route = _match_wasm_extension_ui_route(extension, body.get("path")) + auth = ui_route["auth"] + + if auth == "user": + account = await check_account_exists(request, access_token, usr) + user_id: str | None = account.id + else: + user_id = await _optional_wasm_user_id(request, access_token, usr) + + granted_permission_ids = await _wasm_extension_granted_permission_ids(extension) + + return _wasm_extension_frame_config( + extension, + ui_route["frame_path"], + auth, + ui_route["path_params"], + ui_route["route_params"], + _read_wasm_extension_route_query(body.get("query")), + user_id, + granted_permission_ids, + ) + + app.add_api_route( + route_path, + create_wasm_extension_frame_config, + methods=["POST"], + name=f"{extension.id}:frame-config", + include_in_schema=False, + ) + + +def _add_wasm_extension_wrapper_route( + app: FastAPI, + extension: WasmExtension, + route_path: str, + auth: str, +) -> None: + if _has_route(app, route_path, "GET"): + return + + async def serve_private_wasm_extension_page( + request: Request, + account: Account = Depends(check_account_exists), + ) -> Any: + user = await get_user_from_account(account) + return wasm_extension_wrapper_response( + request, + extension, + auth, + user.json() if user else None, + ) + + async def serve_public_wasm_extension_page(request: Request) -> Any: + return wasm_extension_wrapper_response( + request, + extension, + auth, + None, + ) + + app.add_api_route( + route_path, + ( + serve_public_wasm_extension_page + if auth == "public" + else serve_private_wasm_extension_page + ), + methods=["GET"], + name=f"{extension.id}:{route_path}", + include_in_schema=False, + ) + + +def _add_wasm_extension_frame_route( + app: FastAPI, + extension: WasmExtension, + frame_path: str, + entrypoint: Path, +) -> None: + if _has_route(app, frame_path, "GET"): + return + + async def serve_wasm_extension_frame( + request: Request, + user_id: str | None = Depends(_optional_wasm_user_id), + ) -> FileResponse: + consume_wasm_extension_frame_token(request, extension, frame_path, user_id) + response = FileResponse(entrypoint) + response.headers["Content-Security-Policy"] = wasm_extension_frame_csp( + request, extension + ) + response.headers["Cache-Control"] = "no-store" + response.headers["Cross-Origin-Opener-Policy"] = "same-origin" + response.headers["Cross-Origin-Resource-Policy"] = "same-origin" + # Extension access goes through the parent bridge. + response.headers["Permissions-Policy"] = ( + "camera=(), microphone=(), geolocation=(), payment=(), " + "clipboard-read=(), usb=()" + ) + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["X-Content-Type-Options"] = "nosniff" + return response + + app.add_api_route( + frame_path, + serve_wasm_extension_frame, + methods=["GET"], + name=f"{extension.id}:frame:{frame_path}", + include_in_schema=False, + ) + + +def _wasm_extension_bridge_api_routes( + extension: WasmExtension, + public: bool, +) -> list[dict[str, str]]: + routes: list[dict[str, str]] = [] + for route_config in extension.config.api_routes: + auth = _wasm_extension_route_auth(extension, route_config.auth) + if public and auth != "public": + continue + method = _wasm_extension_api_method(extension, route_config.method) + path = _wasm_extension_api_path(extension, route_config.path) + _wasm_extension_api_export(extension, route_config.export) + routes.append( + { + "method": method, + "path": path, + "pattern": _path_template_pattern(path), + } + ) + return routes + + +def _wasm_extension_frame_config_path(extension: WasmExtension) -> str: + return f"/api/v1/ext/{extension.id}/_ui/frame" + + +def _match_wasm_extension_ui_route( + extension: WasmExtension, + path: Any, +) -> dict[str, Any]: + if not isinstance(path, str) or not path.startswith("/"): + raise HTTPException(status_code=404, detail="Not found") + + for route_index, route_config in enumerate(extension.config.ui_routes): + route_path = _wasm_extension_ui_route_path(extension, route_config.path) + route_params = _path_template_params(route_path, path) + if route_params is None: + continue + + return { + "frame_path": f"/ext-frame/{extension.id}/{route_index}", + "auth": _wasm_extension_route_auth(extension, route_config.auth), + "path_params": route_config.path_params, + "route_params": route_params, + } + + raise HTTPException(status_code=404, detail="Not found") + + +def _path_template_params(template: str, path: str) -> dict[str, str] | None: + template_parts = _path_parts(template) + path_parts = _path_parts(path) + if len(template_parts) != len(path_parts): + return None + + params: dict[str, str] = {} + for template_part, path_part in zip(template_parts, path_parts, strict=False): + if template_part.startswith("{") and template_part.endswith("}"): + param_name = template_part[1:-1] + if not param_name: + return None + params[param_name] = path_part + continue + + if template_part != path_part: + return None + + return params + + +def _path_parts(path: str) -> list[str]: + return [part for part in path.strip("/").split("/") if part] + + +def _wasm_extension_frame_config( + extension: WasmExtension, + frame_path: str, + auth: str, + path_params: dict[str, str], + route_params: dict[str, str], + query: dict[str, Any], + user_id: str | None, + permissions: set[str], +) -> dict[str, Any]: + public = auth == "public" + return { + "extension": { + "id": extension.id, + "name": extension.name, + }, + "frameUrl": wasm_extension_frame_url(extension, frame_path, user_id), + "bridge": { + "extensionId": extension.id, + "public": public, + "routeParams": _map_wasm_extension_route_params(route_params, path_params), + "query": query, + "permissions": sorted(permissions), + "apiRoutes": _wasm_extension_bridge_api_routes(extension, public), + }, + } + + +async def _wasm_extension_granted_permission_ids( + extension: WasmExtension, +) -> set[str]: + installed_extension = await get_installed_extension(extension.id) + if not installed_extension: + return set() + return {permission.id for permission in installed_extension.permissions} + + +def _map_wasm_extension_route_params( + route_params: dict[str, str], + path_params: dict[str, str], +) -> dict[str, str]: + payload: dict[str, str] = {} + for key, value in route_params.items(): + target = path_params.get(key) or _snake_to_camel(key) + payload[target] = value + return payload + + +def _read_wasm_extension_route_query(query: Any) -> dict[str, Any]: + if not isinstance(query, dict): + return {} + + payload: dict[str, Any] = {} + for key, value in query.items(): + if value is None: + continue + payload[_snake_to_camel(str(key))] = value + return payload + + +async def _optional_wasm_user_id( + request: Request, + access_token: Annotated[str | None, Depends(check_access_token)], + usr: UUID4 | None = None, +) -> str | None: + try: + return await optional_user_id(request, access_token, usr) + except HTTPException: + return None + + +def _wasm_extension_ui_route_path(extension: WasmExtension, path: Any) -> str: + if not isinstance(path, str) or not path.startswith("/"): + raise ValueError(f"Invalid route path for WASM extension '{extension.id}'.") + if path == "/": + return "/ext" + return f"/ext{path}" + + +def _wasm_extension_entrypoint(extension: WasmExtension, entrypoint: Any) -> Path: + if not isinstance(entrypoint, str) or not entrypoint: + raise ValueError( + f"Invalid route entrypoint for WASM extension '{extension.id}'." + ) + if entrypoint.startswith("/"): + raise ValueError( + f"Route entrypoint for WASM extension '{extension.id}' must be a " + "relative extension path." + ) + + path = (extension.root_path / entrypoint).resolve() + root_path = extension.root_path.resolve() + if path != root_path and root_path not in path.parents: + raise ValueError(f"Route entrypoint escapes extension root: {entrypoint}") + + static_path = (extension.root_path / "static").resolve() + if path == static_path or static_path in path.parents: + raise ValueError( + f"Route entrypoint for WASM extension '{extension.id}' must not be " + "inside the static asset directory." + ) + if path.suffix.lower() != ".html": + raise ValueError( + f"Route entrypoint for WASM extension '{extension.id}' must be " + "an HTML file." + ) + if not path.is_file(): + raise FileNotFoundError(f"Route entrypoint not found: {path}") + return path diff --git a/lnbits/core/wasm_ext/storage/__init__.py b/lnbits/core/wasm_ext/storage/__init__.py new file mode 100644 index 000000000..7362e2b83 --- /dev/null +++ b/lnbits/core/wasm_ext/storage/__init__.py @@ -0,0 +1,19 @@ +from .crud import ( + migrate_wasm_extension_database, + storage_delete_row, + storage_get_paginated_rows, + storage_get_public_row, + storage_get_row, + storage_get_row_owner_id, + storage_set_row, +) + +__all__ = [ + "migrate_wasm_extension_database", + "storage_delete_row", + "storage_get_paginated_rows", + "storage_get_public_row", + "storage_get_row", + "storage_get_row_owner_id", + "storage_set_row", +] diff --git a/lnbits/core/wasm_ext/storage/crud.py b/lnbits/core/wasm_ext/storage/crud.py new file mode 100644 index 000000000..f308a34eb --- /dev/null +++ b/lnbits/core/wasm_ext/storage/crud.py @@ -0,0 +1,648 @@ +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from loguru import logger + +from lnbits.core.crud import update_migration_version +from lnbits.core.db import db as core_db +from lnbits.core.models import DbVersion +from lnbits.core.models.extensions import InstallableExtension +from lnbits.db import POSTGRES, SQLITE, Compat, Connection, Database +from lnbits.settings import settings + +_MIGRATION_FILE_RE = re.compile(r"^(\d+)_.*\.json$") +_SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +OWNER_ID_FIELD = "__lnbits_owner_id__" + + +async def storage_get_row( + ext_id: str, + table: str, + row_id: str, + owner_id: str, +) -> dict[str, Any] | None: + table_schema = _load_table_schema(ext_id, table) + query = f""" + SELECT * FROM {_table_ref_for_schema(ext_id, table)} + WHERE id = :id AND {OWNER_ID_FIELD} = :owner_id + """ # noqa: S608 + async with Database(f"ext_{ext_id}").connect() as conn: + row = await conn.fetchone(query, {"id": row_id, "owner_id": owner_id}) + return _row_from_db(table_schema, row) if row else None + + +async def storage_get_public_row( + ext_id: str, + table: str, + row_id: str, +) -> dict[str, Any] | None: + table_schema = _load_table_schema(ext_id, table) + query = f""" + SELECT * FROM {_table_ref_for_schema(ext_id, table)} + WHERE id = :id + """ # noqa: S608 + async with Database(f"ext_{ext_id}").connect() as conn: + row = await conn.fetchone(query, {"id": row_id}) + return _row_from_db(table_schema, row) if row else None + + +async def storage_get_row_owner_id( + ext_id: str, + table: str, + row_id: str, +) -> str | None: + _load_table_schema(ext_id, table) + query = f""" + SELECT {OWNER_ID_FIELD} FROM {_table_ref_for_schema(ext_id, table)} + WHERE id = :id + """ # noqa: S608 + async with Database(f"ext_{ext_id}").connect() as conn: + row = await conn.fetchone(query, {"id": row_id}) + + owner_id = row[OWNER_ID_FIELD] if row else None + return owner_id if isinstance(owner_id, str) and owner_id else None + + +async def storage_set_row( + ext_id: str, + table: str, + data: dict[str, Any], + owner_id: str, +) -> None: + table_schema = _load_table_schema(ext_id, table) + clean_data = _data_to_db(table_schema, data, require_id=True) + columns = list(clean_data.keys()) + database = Database(f"ext_{ext_id}") + fields = _fields_by_name(table_schema) + placeholders = [ + _value_placeholder(database, fields[column], column) for column in columns + ] + + clean_data[OWNER_ID_FIELD] = owner_id + columns.append(OWNER_ID_FIELD) + placeholders.append(f":{OWNER_ID_FIELD}") + updates = [ + f"{column} = excluded.{column}" + for column in columns + if column not in ("id", OWNER_ID_FIELD) + ] + conflict_sql = ( + "DO UPDATE SET " + + ", ".join(updates) + + f" WHERE storage_row.{OWNER_ID_FIELD} = :{OWNER_ID_FIELD}" + if updates + else "DO NOTHING" + ) + query = f""" + INSERT INTO {_table_ref_for_schema(ext_id, table)} AS storage_row + ({", ".join(columns)}) + VALUES + ({", ".join(placeholders)}) + ON CONFLICT (id) {conflict_sql} + """ # noqa: S608 + + async with database.connect() as conn: + await conn.execute(query, clean_data) + + +async def storage_get_paginated_rows( + ext_id: str, + table: str, + filters: dict[str, Any], + *, + owner_id: str, + search: str | None, + search_fields: list[str], + sort_by: str | None, + descending: bool, + limit: int, + offset: int, +) -> dict[str, Any]: + table_schema = _load_table_schema(ext_id, table) + database = Database(f"ext_{ext_id}") + where_sql, values = _where_sql( + database, table_schema, filters, search, search_fields + ) + where_sql = _append_owner_where_sql(where_sql) + values[OWNER_ID_FIELD] = owner_id + order_sql = _order_sql(table_schema, sort_by, descending) + count_values = dict(values) + values.update({"limit": min(limit, 1000), "offset": offset}) + + table_ref = _table_ref_for_schema(ext_id, table) + rows_query = f""" + SELECT * FROM {table_ref} + {where_sql} + {order_sql} + LIMIT :limit + OFFSET :offset + """ # noqa: S608 + count_query = f""" + SELECT COUNT(*) AS count FROM {table_ref} + {where_sql} + """ # noqa: S608 + + async with database.connect() as conn: + rows = await conn.fetchall(rows_query, values) + count_row = await conn.fetchone(count_query, count_values) + + return { + "data": [_row_from_db(table_schema, row) for row in rows], + "total": int(count_row["count"]) if count_row else 0, + } + + +async def storage_delete_row( + ext_id: str, + table: str, + row_id: str, + owner_id: str, +) -> None: + _load_table_schema(ext_id, table) + query = f""" + DELETE FROM {_table_ref_for_schema(ext_id, table)} + WHERE id = :id AND {OWNER_ID_FIELD} = :owner_id + """ # noqa: S608 + async with Database(f"ext_{ext_id}").connect() as conn: + await conn.execute(query, {"id": row_id, "owner_id": owner_id}) + + +async def migrate_wasm_extension_database( + ext: InstallableExtension, + current_version: DbVersion | None = None, +) -> None: + migrations_dir = ext.ext_dir / "storage" / "migrations" + migration_files = _migration_files(migrations_dir) + if not migration_files: + logger.debug(f"No storage migrations for WASM extension '{ext.id}'.") + return + + ext_db = Database(f"ext_{ext.id}") + async with ext_db.connect() as conn: + for version, path in migration_files: + if current_version and version <= current_version.version: + continue + logger.debug(f"running WASM storage migration {ext.id}.{version}") + print(f"running migration {ext.id}.{version}") + await _run_storage_migration(conn, path) + await _update_wasm_migration_version(conn, ext.id, version) + + +def _migration_files(migrations_dir: Path) -> list[tuple[int, Path]]: + if not migrations_dir.is_dir(): + return [] + + files: list[tuple[int, Path]] = [] + for path in migrations_dir.glob("*.json"): + match = _MIGRATION_FILE_RE.match(path.name) + if not match: + raise ValueError(f"Invalid WASM storage migration filename: {path.name}") + files.append((int(match.group(1)), path)) + return sorted(files) + + +async def _run_storage_migration(db: Connection, path: Path) -> None: + migration = _load_json(path) + operations = migration.get("operations") + if not isinstance(operations, list): + raise ValueError(f"WASM storage migration '{path}' has no operations list.") + + for operation in operations: + if not isinstance(operation, dict): + raise ValueError(f"WASM storage migration '{path}' has invalid operation.") + sql = _operation_sql(db, operation) + await db.execute(sql) + + +def _operation_sql(db: Connection, operation: dict[str, Any]) -> str: + op = operation.get("op") + if op == "create_table": + return _create_table_sql(db, operation) + if op == "add_field": + return _add_field_sql(db, operation) + if op == "create_index": + return _create_index_sql(db, operation) + raise ValueError(f"Unsupported WASM storage migration operation: {op}") + + +def _create_table_sql(db: Connection, operation: dict[str, Any]) -> str: + table = _require_identifier(operation, "table") + fields = _require_fields(operation) + if not any(field.get("name") == "id" for field in fields): + raise ValueError(f"WASM storage table '{table}' must define an id field.") + if any(field.get("name") == OWNER_ID_FIELD for field in fields): + raise ValueError( + f"WASM storage table '{table}' defines reserved field '{OWNER_ID_FIELD}'." + ) + + columns = [ + _column_sql(db, field, primary_key=field.get("name") == "id") + for field in fields + ] + columns.append(f"{OWNER_ID_FIELD} TEXT NOT NULL") + return f""" + CREATE TABLE IF NOT EXISTS {_table_ref(db, table)} ( + {", ".join(columns)} + ); + """ + + +def _add_field_sql(db: Connection, operation: dict[str, Any]) -> str: + table = _require_identifier(operation, "table") + field = _field_from_add_field_operation(operation) + if field["name"] == OWNER_ID_FIELD: + raise ValueError( + f"WASM storage table '{table}' cannot add reserved field " + f"'{OWNER_ID_FIELD}'." + ) + return f""" + ALTER TABLE {_table_ref(db, table)} + ADD COLUMN {_column_sql(db, field)}; + """ + + +def _create_index_sql(db: Connection, operation: dict[str, Any]) -> str: + table = _require_identifier(operation, "table") + name = _require_identifier(operation, "name") + field = _require_identifier(operation, "field") + if field == OWNER_ID_FIELD: + raise ValueError( + f"WASM storage table '{table}' cannot index reserved field " + f"'{OWNER_ID_FIELD}'." + ) + + if db.type == SQLITE and db.schema: + return f""" + CREATE INDEX IF NOT EXISTS {_schema_ref(db, name)} + ON {table} ({field}); + """ + + return f""" + CREATE INDEX IF NOT EXISTS {name} + ON {_table_ref(db, table)} ({field}); + """ + + +def _column_sql( + db: Connection, + field: dict[str, Any], + *, + primary_key: bool = False, +) -> str: + name = _require_identifier(field, "name") + column_type = _field_type_sql(db, field) + parts = [name, column_type] + + if primary_key: + parts.append("PRIMARY KEY") + elif not field.get("nullable", False): + parts.append("NOT NULL") + + if "default" in field: + parts.append(f"DEFAULT {_default_sql(field['default'])}") + + return " ".join(parts) + + +def _field_type_sql(db: Connection, field: dict[str, Any]) -> str: + if field.get("list") is True: + return "TEXT" + + field_type = field.get("type") + if field_type == "string": + return "TEXT" + if field_type == "integer": + return db.big_int + if field_type == "number": + return "DOUBLE PRECISION" if db.type == POSTGRES else "REAL" + if field_type == "boolean": + return "BOOLEAN" + if field_type == "datetime": + return "TIMESTAMP" + raise ValueError(f"Unsupported WASM storage field type: {field_type}") + + +def _load_table_schema(ext_id: str, table: str) -> dict[str, Any]: + schema = _load_storage_schema(ext_id) + tables = schema.get("tables") + if not isinstance(tables, dict): + raise ValueError(f"WASM extension '{ext_id}' has no storage tables schema.") + + _require_identifier({"table": table}, "table") + table_schema = tables.get(table) + if not isinstance(table_schema, dict): + raise ValueError(f"WASM extension '{ext_id}' has no storage table '{table}'.") + + fields = table_schema.get("fields") + if not isinstance(fields, list) or not fields: + raise ValueError(f"WASM storage table '{table}' has no fields schema.") + + for field in fields: + if not isinstance(field, dict): + raise ValueError(f"WASM storage table '{table}' has invalid field schema.") + _require_identifier(field, "name") + if field["name"] == OWNER_ID_FIELD: + raise ValueError( + f"WASM storage table '{table}' defines reserved field " + f"'{OWNER_ID_FIELD}'." + ) + return table_schema + + +def _load_storage_schema(ext_id: str) -> dict[str, Any]: + schema_path = ( + Path(settings.lnbits_extensions_path) + / "extensions" + / ext_id + / "storage" + / "schema.json" + ) + if not schema_path.is_file(): + raise ValueError(f"WASM extension '{ext_id}' has no storage schema.") + return _load_json(schema_path) + + +def _data_to_db( + table_schema: dict[str, Any], + data: dict[str, Any], + *, + require_id: bool, +) -> dict[str, Any]: + if not isinstance(data, dict): + raise ValueError("WASM storage row data must be an object.") + if require_id and not data.get("id"): + raise ValueError("WASM storage row data must include an id.") + _reject_reserved_owner_field(data, "row") + + fields = _fields_by_name(table_schema) + unknown_fields = sorted(set(data) - set(fields)) + if unknown_fields: + raise ValueError( + "WASM storage row has unknown fields: " + ", ".join(unknown_fields) + ) + + return { + field_name: _value_to_db(fields[field_name], value) + for field_name, value in data.items() + } + + +def _filters_to_db( + table_schema: dict[str, Any], + filters: dict[str, Any], +) -> dict[str, Any]: + if not isinstance(filters, dict): + raise ValueError("WASM storage filters must be an object.") + _reject_reserved_owner_field(filters, "filters") + + fields = _fields_by_name(table_schema) + unknown_fields = sorted(set(filters) - set(fields)) + if unknown_fields: + raise ValueError( + "WASM storage filters have unknown fields: " + ", ".join(unknown_fields) + ) + + return { + field_name: _value_to_db(fields[field_name], value) + for field_name, value in filters.items() + } + + +def _value_placeholder(db: Compat, field: dict[str, Any], key: str) -> str: + if field.get("type") == "datetime" and not field.get("list"): + return db.timestamp_placeholder(key) + return f":{key}" + + +def _where_sql( + db: Compat, + table_schema: dict[str, Any], + filters: dict[str, Any], + search: str | None, + search_fields: list[str], +) -> tuple[str, dict[str, Any]]: + clean_filters = _filters_to_db(table_schema, filters) + fields = _fields_by_name(table_schema) + clauses = [ + f"{field} = {_value_placeholder(db, fields[field], f'filter_{field}')}" + for field in clean_filters + ] + values = {f"filter_{field}": value for field, value in clean_filters.items()} + + clean_search = search.strip().lower() if search else "" + if clean_search: + fields = _fields_by_name(table_schema) + invalid_fields = sorted(set(search_fields) - set(fields)) + if invalid_fields: + raise ValueError( + "WASM storage search has unknown fields: " + ", ".join(invalid_fields) + ) + if search_fields: + search_clause = " OR ".join( + f"LOWER(CAST({field} AS TEXT)) LIKE :search" for field in search_fields + ) + clauses.append(f"({search_clause})") + values["search"] = f"%{clean_search}%" + + return ("WHERE " + " AND ".join(clauses), values) if clauses else ("", values) + + +def _append_owner_where_sql(where_sql: str) -> str: + owner_clause = f"{OWNER_ID_FIELD} = :{OWNER_ID_FIELD}" + if where_sql: + return f"{where_sql} AND {owner_clause}" + return f"WHERE {owner_clause}" + + +def _order_sql( + table_schema: dict[str, Any], + sort_by: str | None, + descending: bool, +) -> str: + if not sort_by: + return "" + fields = _fields_by_name(table_schema) + if sort_by not in fields: + raise ValueError(f"WASM storage sort field is unknown: {sort_by}") + direction = "DESC" if descending else "ASC" + return f"ORDER BY {sort_by} {direction}" + + +def _row_from_db( + table_schema: dict[str, Any], + row: dict[str, Any], +) -> dict[str, Any]: + fields = _fields_by_name(table_schema) + return { + field_name: _value_from_db(fields[field_name], value) + for field_name, value in dict(row).items() + if field_name in fields + } + + +def _fields_by_name(table_schema: dict[str, Any]) -> dict[str, dict[str, Any]]: + fields = table_schema.get("fields") + if not isinstance(fields, list): + raise ValueError("WASM storage table schema fields must be a list.") + return {field["name"]: field for field in fields} + + +def _reject_reserved_owner_field(data: dict[str, Any], value_name: str) -> None: + if OWNER_ID_FIELD in data: + raise ValueError(f"WASM storage {value_name} includes a reserved owner field.") + + +def _value_to_db(field: dict[str, Any], value: Any) -> Any: # noqa: C901 + if value is None: + if field.get("nullable", False): + return None + raise ValueError(f"WASM storage field '{field['name']}' cannot be null.") + + if field.get("list") is True: + if not isinstance(value, list): + raise ValueError(f"WASM storage field '{field['name']}' must be a list.") + return json.dumps(value) + + field_type = field.get("type") + if field_type == "string": + if not isinstance(value, str): + raise ValueError(f"WASM storage field '{field['name']}' must be a string.") + return value + if field_type == "integer": + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError( + f"WASM storage field '{field['name']}' must be an integer." + ) + return value + if field_type == "number": + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError(f"WASM storage field '{field['name']}' must be a number.") + return value + if field_type == "boolean": + if not isinstance(value, bool): + raise ValueError(f"WASM storage field '{field['name']}' must be a boolean.") + return value + if field_type == "datetime": + if isinstance(value, int | float): + return datetime.fromtimestamp(value, tz=timezone.utc) + if isinstance(value, datetime): + return value + raise ValueError( + f"WASM storage field '{field['name']}' must be a Unix timestamp." + ) + raise ValueError(f"Unsupported WASM storage field type: {field_type}") + + +def _value_from_db(field: dict[str, Any], value: Any) -> Any: + if value is None: + return None + + if field.get("list") is True: + if isinstance(value, str): + return json.loads(value) + return value + + field_type = field.get("type") + if field_type == "boolean": + return bool(value) + if field_type == "datetime": + if isinstance(value, datetime): + return int(value.replace(tzinfo=timezone.utc).timestamp()) + if isinstance(value, int | float): + return int(value) + if isinstance(value, str): + try: + return int(datetime.fromisoformat(value).timestamp()) + except ValueError: + return value + return value + + +def _default_sql(value: Any) -> str: + if value is None: + return "NULL" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int | float): + return str(value) + if isinstance(value, str): + return _quote_sql_string(value) + if isinstance(value, list | dict): + return _quote_sql_string(json.dumps(value)) + raise ValueError(f"Unsupported WASM storage default value: {value}") + + +def _field_from_add_field_operation(operation: dict[str, Any]) -> dict[str, Any]: + field = { + "name": operation.get("field"), + "type": operation.get("type"), + } + for key in ("default", "list", "nullable"): + if key in operation: + field[key] = operation[key] + return field + + +def _require_fields(operation: dict[str, Any]) -> list[dict[str, Any]]: + fields = operation.get("fields") + if not isinstance(fields, list) or not fields: + raise ValueError("WASM storage create_table operation requires fields.") + if not all(isinstance(field, dict) for field in fields): + raise ValueError("WASM storage fields must be objects.") + return fields + + +def _require_identifier(data: dict[str, Any], key: str) -> str: + value = data.get(key) + if not isinstance(value, str) or not _SQL_IDENTIFIER_RE.match(value): + raise ValueError(f"Invalid WASM storage SQL identifier for '{key}': {value}") + return value + + +def _table_ref(db: Connection, table: str) -> str: + if db.schema: + return f"{_schema_ref(db, table)}" + return table + + +def _table_ref_for_schema(ext_id: str, table: str) -> str: + _require_identifier({"schema": ext_id}, "schema") + _require_identifier({"table": table}, "table") + return f"{ext_id}.{table}" + + +def _schema_ref(db: Connection, name: str) -> str: + if not db.schema: + return name + if not _SQL_IDENTIFIER_RE.match(db.schema): + raise ValueError(f"Invalid WASM extension storage schema: {db.schema}") + return f"{db.schema}.{name}" + + +def _quote_sql_string(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def _load_json(path: Path) -> dict[str, Any]: + with open(path, encoding="utf-8") as json_file: + data = json.load(json_file) + if not isinstance(data, dict): + raise ValueError(f"WASM storage migration '{path}' must be a JSON object.") + return data + + +async def _update_wasm_migration_version( + db: Connection, + ext_id: str, + version: int, +) -> None: + if db.schema is None: + await update_migration_version(db, ext_id, version) + else: + async with core_db.connect() as conn: + await update_migration_version(conn, ext_id, version) diff --git a/lnbits/core/wasm_ext/wasm/__init__.py b/lnbits/core/wasm_ext/wasm/__init__.py new file mode 100644 index 000000000..9d61f30d0 --- /dev/null +++ b/lnbits/core/wasm_ext/wasm/__init__.py @@ -0,0 +1,13 @@ +from .component import warm_wasm_extension +from .events import dispatch_wasm_invoice_paid +from .invoke import invoke_wasm_extension_export +from .loader import WasmExtension, is_wasm_extension_dir, is_wasm_extension_id + +__all__ = [ + "WasmExtension", + "dispatch_wasm_invoice_paid", + "invoke_wasm_extension_export", + "is_wasm_extension_dir", + "is_wasm_extension_id", + "warm_wasm_extension", +] diff --git a/lnbits/core/wasm_ext/wasm/component.py b/lnbits/core/wasm_ext/wasm/component.py new file mode 100644 index 000000000..e95ec9fe2 --- /dev/null +++ b/lnbits/core/wasm_ext/wasm/component.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from functools import lru_cache +from typing import Any + +from wasmtime import Config, Engine + +from lnbits.settings import settings + +from .loader import WasmExtension + + +def warm_wasm_extension(extension: WasmExtension) -> None: + _wasm_component(extension) + + +@lru_cache(maxsize=8) +def _wasm_engine(max_wasm_stack_bytes: int | None = None) -> Any: + config = Config() + config.wasm_component_model = True + config.epoch_interruption = True + config.consume_fuel = True + stack_limit = ( + settings.wasm_runtime_max_wasm_stack_bytes + if max_wasm_stack_bytes is None + else max_wasm_stack_bytes + ) + if stack_limit > 0: + config.max_wasm_stack = stack_limit + return Engine(config) + + +def _wasm_component( + extension: WasmExtension, + limits: dict[str, int] | None = None, +) -> Any: + stat = extension.module_path.stat() + max_wasm_stack_bytes = ( + limits["wasm_runtime_max_wasm_stack_bytes"] + if limits + else settings.wasm_runtime_max_wasm_stack_bytes + ) + return _cached_wasm_component( + str(extension.module_path), + stat.st_mtime_ns, + stat.st_size, + max_wasm_stack_bytes, + ) + + +@lru_cache(maxsize=32) +def _cached_wasm_component( + module_path: str, + mtime_ns: int, + size: int, + max_wasm_stack_bytes: int, +) -> Any: + from wasmtime import component + + return component.Component.from_file( + _wasm_engine(max_wasm_stack_bytes), module_path + ) diff --git a/lnbits/core/wasm_ext/wasm/config.py b/lnbits/core/wasm_ext/wasm/config.py new file mode 100644 index 000000000..0b18e8450 --- /dev/null +++ b/lnbits/core/wasm_ext/wasm/config.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import re +from typing import Any, Literal + +from pydantic import ( + BaseModel, + Field, + StrictBool, + StrictStr, + ValidationError, +) + +from lnbits.core.models.extensions import ExtensionPermission + +_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") + + +class _StrictWasmModel(BaseModel): + class Config: + extra = "ignore" + allow_population_by_field_name = True + + +class WasmExtensionExport(_StrictWasmModel): + name: StrictStr + visibility: Literal["authenticated", "event", "public"] + + +class WasmRuntimeConfig(_StrictWasmModel): + module: StrictStr + wit: StrictStr | None = None + world: StrictStr = "" + exports: list[WasmExtensionExport] = Field(default_factory=list) + + +class WasmUIConfig(_StrictWasmModel): + entrypoint: StrictStr | None = None + sandbox: StrictBool | None = None + + +class WasmSDKConfig(_StrictWasmModel): + frontend_js: StrictStr | None = None + + +class WasmUIRouteConfig(_StrictWasmModel): + path: StrictStr + entrypoint: StrictStr + auth: Literal["public", "user"] + path_params: dict[str, StrictStr] = Field(default_factory=dict) + + +class WasmAPIRouteConfig(_StrictWasmModel): + method: Literal["DELETE", "GET", "PATCH", "POST", "PUT"] + path: StrictStr + export: StrictStr + auth: Literal["public", "user"] + path_params: dict[str, StrictStr] = Field(default_factory=dict) + + +class WasmEventsConfig(_StrictWasmModel): + on_invoice_paid: StrictStr | None = Field(None, alias="onInvoicePaid") + + +class WasmExtensionConfig(_StrictWasmModel): + id: StrictStr + name: StrictStr + short_description: StrictStr + tile: StrictStr | None = None + version: StrictStr + min_lnbits_version: StrictStr | None = None + max_lnbits_version: StrictStr | None = None + extension_type: Literal["wasm"] + wasm: WasmRuntimeConfig + events: WasmEventsConfig = Field( + default_factory=lambda: WasmEventsConfig.parse_obj({}) + ) + ui: WasmUIConfig | None = None + sdk: WasmSDKConfig | None = None + ui_routes: list[WasmUIRouteConfig] = Field(default_factory=list) + api_routes: list[WasmAPIRouteConfig] = Field(default_factory=list) + permissions: list[ExtensionPermission] = Field(default_factory=list) + + +def parse_wasm_extension_config( + ext_id: str, + config: dict[str, Any], +) -> WasmExtensionConfig: + validate_wasm_extension_config_id(ext_id, config) + try: + return WasmExtensionConfig.parse_obj(config) + except ValidationError as exc: + raise ValueError( + f"Invalid WASM extension config for '{ext_id}': {exc}" + ) from exc + + +def validate_wasm_extension_config_id( + ext_id: str, + config: dict[str, Any] | WasmExtensionConfig, +) -> str: + if not _EXTENSION_ID_RE.fullmatch(ext_id): + raise ValueError(f"Invalid WASM extension id '{ext_id}'.") + + config_id = ( + config.id if isinstance(config, WasmExtensionConfig) else config.get("id") + ) + if not isinstance(config_id, str) or not config_id: + raise ValueError(f"WASM extension '{ext_id}' config must define id.") + if config_id != ext_id: + raise ValueError( + f"WASM extension id mismatch: installed as '{ext_id}' " + f"but config declares '{config_id}'." + ) + return config_id diff --git a/lnbits/core/wasm_ext/wasm/events.py b/lnbits/core/wasm_ext/wasm/events.py new file mode 100644 index 000000000..6a86b1dee --- /dev/null +++ b/lnbits/core/wasm_ext/wasm/events.py @@ -0,0 +1,256 @@ +import json +from collections.abc import Iterable +from typing import Any + +from loguru import logger + +from lnbits.core.crud.extensions import get_installed_extension, get_user_extensions +from lnbits.core.crud.wallets import get_wallet +from lnbits.core.db import core_app_extra +from lnbits.core.models.extensions import ExtensionWalletPaymentsWatchGrant +from lnbits.core.wasm_ext.storage.crud import storage_get_row_owner_id +from lnbits.core.wasm_ext.wasm.invoke import invoke_wasm_extension_export +from lnbits.helpers import sha256s + +WALLET_PAYMENTS_WATCH_PERMISSION = "wallet.payments.watch" + + +async def dispatch_wasm_invoice_paid(payment: Any) -> None: + targets: dict[str, tuple[Any, str | None]] = {} + extension_id = _payment_extension_id(payment) + if extension_id: + extension = core_app_extra.wasm_extension_registry.get(extension_id) + if extension: + targets[extension_id] = ( + extension, + await _wasm_invoice_paid_owner_id(extension, payment), + ) + + wallet = await _payment_wallet(payment) + if wallet: + wallet_owner_id = sha256s(wallet.user) + for watch_extension_id in await _wallet_watch_extension_ids( + wallet.user, wallet.id + ): + extension = core_app_extra.wasm_extension_registry.get(watch_extension_id) + if not extension: + continue + if watch_extension_id in targets: + existing_extension, existing_owner_id = targets[watch_extension_id] + targets[watch_extension_id] = ( + existing_extension, + existing_owner_id or wallet_owner_id, + ) + continue + targets[watch_extension_id] = (extension, wallet_owner_id) + + for extension, owner_id in targets.values(): + await _dispatch_wasm_invoice_paid_to_extension(extension, payment, owner_id) + + +async def _dispatch_wasm_invoice_paid_to_extension( + extension: Any, payment: Any, owner_id: str | None +) -> None: + export_name = _wasm_invoice_paid_export(extension.config) + if not export_name: + return + + if not _is_wasm_event_export(extension, export_name): + logger.warning( + f"WASM extension '{extension.id}' declares invalid onInvoicePaid " + f"export '{export_name}'." + ) + return + + try: + await invoke_wasm_extension_export( + extension.id, + export_name, + _wasm_invoice_paid_payload(payment), + context="event", + owner_id=owner_id, + trigger_type="event", + event_type="invoice_paid", + wallet_id=payment.wallet_id, + payment_hash=payment.payment_hash, + checking_id=payment.checking_id, + ) + except Exception as exc: + logger.warning( + f"WASM extension '{extension.id}' failed to handle paid invoice " + f"'{payment.payment_hash}': {exc!s}" + ) + + +async def _payment_wallet(payment: Any) -> Any | None: + wallet_id = getattr(payment, "wallet_id", None) + if not isinstance(wallet_id, str) or not wallet_id: + return None + try: + return await get_wallet(wallet_id) + except Exception as exc: + logger.warning(f"Could not fetch wallet '{wallet_id}' for WASM event: {exc!s}") + return None + + +async def _wallet_watch_extension_ids(user_id: str, wallet_id: str) -> list[str]: + try: + user_extensions = await get_user_extensions(user_id) + except Exception as exc: + logger.warning( + f"Could not fetch extensions for wallet payment watch user " + f"'{user_id}': {exc!s}" + ) + return [] + extension_ids: list[str] = [] + + for user_extension in user_extensions: + if not user_extension.active: + continue + if not _has_wallet_watch_grant(user_extension, wallet_id): + continue + if not core_app_extra.wasm_extension_registry.get(user_extension.extension): + continue + try: + installed_extension = await get_installed_extension( + user_extension.extension + ) + except Exception as exc: + logger.warning( + f"Could not fetch installed extension '{user_extension.extension}' " + f"for wallet payment watch: {exc!s}" + ) + continue + if not installed_extension or not installed_extension.active: + continue + if not _extension_has_permission( + installed_extension, WALLET_PAYMENTS_WATCH_PERMISSION + ): + continue + extension_ids.append(user_extension.extension) + + return extension_ids + + +def _has_wallet_watch_grant(user_extension: Any, wallet_id: str) -> bool: + permissions = user_extension.permissions or {} + grants = permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION) + if not isinstance(grants, list): + return False + + for grant_data in grants: + if not isinstance(grant_data, dict): + continue + try: + grant = ExtensionWalletPaymentsWatchGrant.parse_obj(grant_data) + except ValueError: + continue + if grant.enabled and grant.wallet_id == wallet_id: + return True + return False + + +def _extension_has_permission(extension: Any, permission_id: str) -> bool: + return any( + ( + permission.get("id") + if isinstance(permission, dict) + else getattr(permission, "id", None) + ) + == permission_id + for permission in (extension.permissions or []) + ) + + +def _payment_extension_id(payment: Any) -> str | None: + if isinstance(payment.extension, str) and payment.extension: + return payment.extension + + extra = payment.extra or {} + tag = extra.get("tag") or payment.tag + return tag if isinstance(tag, str) and tag else None + + +async def _wasm_invoice_paid_owner_id(extension: Any, payment: Any) -> str | None: + source_id = _payment_source_id(payment) + source_tables = await _wasm_public_invoice_source_tables(extension.id) + if not source_id or not source_tables: + return None + + for source_table in source_tables: + owner_id = await storage_get_row_owner_id(extension.id, source_table, source_id) + if owner_id: + return owner_id + return None + + +def _payment_source_id(payment: Any) -> str | None: + extra = payment.extra or {} + source_id = extra.get("source_id") + return source_id if isinstance(source_id, str) and source_id else None + + +async def _wasm_public_invoice_source_tables(extension_id: str) -> list[str]: + installed_extension = await get_installed_extension(extension_id) + if not installed_extension: + return [] + return _wasm_public_invoice_source_tables_from_permissions( + installed_extension.permissions + ) + + +def _wasm_public_invoice_source_tables_from_permissions( + permissions: Iterable[Any], +) -> list[str]: + for permission in permissions: + permission_id = ( + permission.get("id") + if isinstance(permission, dict) + else getattr(permission, "id", None) + ) + if permission_id != "wallet.create_invoice_public": + continue + policies = ( + permission.get("policies") + if isinstance(permission, dict) + else getattr(permission, "policies", None) + ) + if not isinstance(policies, list): + return [] + return [ + source_policy["table"] + for source_policy in policies + if isinstance(source_policy, dict) + and isinstance(source_policy.get("table"), str) + and source_policy["table"] + ] + return [] + + +def _wasm_invoice_paid_export(config: Any) -> str | None: + return config.events.on_invoice_paid + + +def _is_wasm_event_export(extension: Any, export_name: str) -> bool: + for export in extension.exports: + if export.name == export_name: + return export.visibility == "event" + return False + + +def _wasm_invoice_paid_payload(payment: Any) -> dict[str, Any]: + return { + "checkingId": payment.checking_id, + "paymentHash": payment.payment_hash, + "walletId": payment.wallet_id, + "amount": payment.amount, + "fee": payment.fee, + "bolt11": payment.bolt11, + "memo": payment.memo, + "pending": payment.pending, + "status": payment.status, + "tag": payment.tag, + "extension": payment.extension, + "extra": payment.extra or {}, + "payment": json.loads(payment.json()), + } diff --git a/lnbits/core/wasm_ext/wasm/host.py b/lnbits/core/wasm_ext/wasm/host.py new file mode 100644 index 000000000..e89779a70 --- /dev/null +++ b/lnbits/core/wasm_ext/wasm/host.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import asyncio +import re +from collections.abc import Mapping +from typing import Any + +from wasmtime import component + +from ..api.models import EmptyRequest +from ..api.registry import list_extension_api_methods +from ..api.runtime import ExtensionAPIHost + + +def add_extension_host_imports( + linker: Any, + api_host: ExtensionAPIHost, + event_loop: asyncio.AbstractEventLoop, +) -> None: + with linker.root() as root: + methods_by_interface: dict[str, list[Any]] = {} + for method in list_extension_api_methods(): + methods_by_interface.setdefault(method.host_interface, []).append(method) + + for host_interface, methods in methods_by_interface.items(): + with root.add_instance(f"lnbits:extension/{host_interface}") as host: + for method in methods: + host.add_func( + method.host_name.replace("_", "-"), + _make_host_import( + api_host, + method.method_id, + method.request_model is EmptyRequest, + event_loop, + ), + ) + + +def _make_host_import( + api_host: ExtensionAPIHost, + host_name: str, + empty_request: bool, + event_loop: asyncio.AbstractEventLoop, +) -> Any: + if empty_request: + + def empty_host_import(_store: Any) -> Any: + future = asyncio.run_coroutine_threadsafe( + api_host.invoke(host_name), event_loop + ) + response = future.result() + return _dict_to_component_record(response) + + return empty_host_import + + def host_import(_store: Any, request: Any = None) -> Any: + payload = _component_payload_to_dict(request) + future = asyncio.run_coroutine_threadsafe( + api_host.invoke(host_name, payload), event_loop + ) + response = future.result() + return _dict_to_component_record(response) + + return host_import + + +def _component_payload_to_dict(value: Any) -> dict[str, Any]: + if value is None: + return {} + if hasattr(value, "__dict__"): + return dict(value.__dict__) + if isinstance(value, Mapping): + return dict(value) + raise TypeError("WASM host function payload must be a record.") + + +def _dict_to_component_record(value: Mapping[str, Any]) -> Any: + + record = component.Record() + for key, item in value.items(): + setattr(record, _camel_to_kebab(key), _to_component_value(item)) + return record + + +def _to_component_value(value: Any) -> Any: + if isinstance(value, Mapping): + return _dict_to_component_record(value) + if isinstance(value, list): + return [_to_component_value(item) for item in value] + return value + + +def _camel_to_kebab(value: str) -> str: + return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value).replace("_", "-").lower() diff --git a/lnbits/core/wasm_ext/wasm/invoke.py b/lnbits/core/wasm_ext/wasm/invoke.py new file mode 100644 index 000000000..a13c968be --- /dev/null +++ b/lnbits/core/wasm_ext/wasm/invoke.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +import asyncio +import json +from collections.abc import Mapping +from typing import Any + +from wasmtime import Store, WasiConfig, component + +from lnbits.core.crud.extensions import get_installed_extension +from lnbits.core.db import core_app_extra +from lnbits.settings import settings + +from ..api.host import ExtensionHostAPI +from ..api.runtime import ExtensionAPIHost +from .component import _wasm_component, _wasm_engine +from .host import add_extension_host_imports +from .loader import WasmExtension + +_WASM_EPOCH_DEADLINE_TICKS = 1_000_000_000 +_WASM_UNLIMITED_FUEL = 2**63 - 1 + + +async def invoke_wasm_extension_export( + ext_id: str, + export_name: str, + payload: Mapping[str, Any] | None = None, + *, + user: Any | None = None, + access_token: str | None = None, + context: str = "user", + owner_id: str | None = None, + trigger_type: str = "unknown", + request_id: str | None = None, + method: str | None = None, + path: str | None = None, + event_type: str | None = None, + wallet_id: str | None = None, + payment_hash: str | None = None, + checking_id: str | None = None, + request_bytes: int | None = None, + context_data: dict | None = None, +) -> dict[str, Any]: + from lnbits.core.services.extensions import ( + finish_wasm_invocation, + get_wasm_invocation_stop_reason, + resolve_wasm_runtime_limits, + start_wasm_invocation, + stop_wasm_invocation, + wasm_invocation_stop_requested, + ) + + extension = _get_registered_extension(ext_id) + installed_extension = await _active_installed_extension(extension) + permissions = installed_extension.permissions + limits = resolve_wasm_runtime_limits(installed_extension) + payload = payload or {} + payload_size = _json_size(payload) + effective_request_bytes = ( + request_bytes if request_bytes is not None else payload_size + ) + _check_wasm_request_size(effective_request_bytes, limits) + invocation = await start_wasm_invocation( + extension_id=extension.id, + export_name=export_name, + trigger_type=trigger_type, + user_id=_user_id(user) or owner_id, + wallet_id=wallet_id, + request_id=request_id, + method=method, + path=path, + event_type=event_type, + payment_hash=payment_hash, + checking_id=checking_id, + request_bytes=effective_request_bytes, + context={"host_context": context, **(context_data or {})}, + runtime_limits=limits, + ) + api = ExtensionHostAPI( + extension.id, + permissions, + user_id=_user_id(user), + access_token=access_token, + context=context, + owner_id=owner_id, + invocation_id=invocation.id, + runtime_limits=limits, + ) + event_loop = asyncio.get_running_loop() + thread_task = asyncio.create_task( + asyncio.to_thread( + _invoke_wasm_extension_export_sync, + extension, + export_name, + payload, + api, + event_loop, + invocation.id, + limits, + ) + ) + max_execution_ms = limits["wasm_runtime_max_execution_ms"] + timed_out = False + finished = False + + try: + try: + if max_execution_ms > 0: + result = await asyncio.wait_for( + asyncio.shield(thread_task), + timeout=max_execution_ms / 1000, + ) + else: + result = await thread_task + except asyncio.TimeoutError as exc: + timed_out = True + stop_reason = "WASM execution time limit exceeded." + await stop_wasm_invocation(invocation.id, reason=stop_reason) + try: + result = await asyncio.wait_for( + asyncio.shield(thread_task), + timeout=2, + ) + except asyncio.TimeoutError: + await finish_wasm_invocation( + invocation.id, + status="timeout", + error_type="TimeoutError", + error_message=stop_reason, + stop_reason=stop_reason, + ) + finished = True + raise TimeoutError(stop_reason) from exc + + status = ( + "timeout" + if timed_out + else ( + "stopped" + if wasm_invocation_stop_requested(invocation.id) + else "completed" + ) + ) + await finish_wasm_invocation( + invocation.id, + status=status, + response_bytes=_json_size(result), + stop_reason=get_wasm_invocation_stop_reason(invocation.id), + ) + finished = True + return result + except Exception as exc: + if not finished: + await finish_wasm_invocation( + invocation.id, + status=( + "timeout" + if timed_out + else ( + "stopped" + if wasm_invocation_stop_requested(invocation.id) + else "failed" + ) + ), + error_type=exc.__class__.__name__, + error_message=str(exc), + stop_reason=get_wasm_invocation_stop_reason(invocation.id), + ) + raise + + +def _invoke_wasm_extension_export_sync( + extension: WasmExtension, + export_name: str, + payload: Mapping[str, Any], + api: ExtensionHostAPI, + event_loop: asyncio.AbstractEventLoop, + invocation_id: str, + limits: dict[str, int], +) -> dict[str, Any]: + from lnbits.core.services.extensions import attach_wasm_invocation_runtime + + engine = _wasm_engine(limits["wasm_runtime_max_wasm_stack_bytes"]) + store = Store(engine) + _set_store_limits(store, limits) + _set_store_fuel(store, limits) + store.set_epoch_deadline(_WASM_EPOCH_DEADLINE_TICKS) + attach_wasm_invocation_runtime(invocation_id, engine=engine, store=store) + store.set_wasi(WasiConfig()) + + linker = component.Linker(engine) + linker.add_wasip2() + add_extension_host_imports(linker, ExtensionAPIHost(api), event_loop) + + wasm_component = _wasm_component(extension, limits) + instance = linker.instantiate(store, wasm_component) + function = instance.get_func(store, export_name) + if not function: + raise KeyError( + f"WASM extension '{extension.id}' has no export '{export_name}'." + ) + + result = function(store, json.dumps(payload)) + function.post_return(store) + return _parse_wasm_export_result(result, limits) + + +def _parse_wasm_export_result(value: Any, limits: dict[str, int]) -> dict[str, Any]: + if isinstance(value, bytes): + value = value.decode() + if not isinstance(value, str): + return {"ok": True, "data": value} + + max_response_bytes = limits["wasm_runtime_max_response_bytes"] + if max_response_bytes > 0: + response_size = len(value.encode()) + if response_size > max_response_bytes: + raise ValueError( + f"WASM extension response is too large: {response_size} bytes." + ) + + parsed = json.loads(value) + if isinstance(parsed, dict): + return parsed + return {"ok": True, "data": parsed} + + +def _get_registered_extension(ext_id: str) -> WasmExtension: + extension = core_app_extra.wasm_extension_registry.get(ext_id) + if extension: + return extension + raise RuntimeError(f"WASM extension '{ext_id}' is not registered.") + + +async def _active_installed_extension(extension: WasmExtension) -> Any: + installed_extension = await get_installed_extension(extension.id) + if ( + not installed_extension + or settings.lnbits_extensions_deactivate_all + or not installed_extension.active + ): + raise PermissionError(f"WASM extension '{extension.id}' is deactivated.") + return installed_extension + + +def _user_id(user: Any | None) -> str | None: + return getattr(user, "id", None) if user else None + + +def _set_store_limits(store: Any, limits: dict[str, int]) -> None: + store.set_limits( + memory_size=_wasm_limit(limits["wasm_runtime_max_memory_bytes"]), + table_elements=_wasm_limit(limits["wasm_runtime_max_table_elements"]), + instances=_wasm_limit(limits["wasm_runtime_max_instances"]), + tables=_wasm_limit(limits["wasm_runtime_max_tables"]), + memories=_wasm_limit(limits["wasm_runtime_max_memories"]), + ) + + +def _set_store_fuel(store: Any, limits: dict[str, int]) -> None: + store.set_fuel( + limits["wasm_runtime_max_fuel"] + if limits["wasm_runtime_max_fuel"] > 0 + else _WASM_UNLIMITED_FUEL + ) + + +def _check_wasm_request_size(request_bytes: int, limits: dict[str, int]) -> None: + max_request_bytes = limits["wasm_runtime_max_request_bytes"] + if max_request_bytes > 0 and request_bytes > max_request_bytes: + raise ValueError(f"WASM extension request is too large: {request_bytes} bytes.") + + +def _wasm_limit(value: int) -> int: + return value if value > 0 else -1 + + +def _json_size(value: Any) -> int: + return len(json.dumps(value, default=str).encode()) diff --git a/lnbits/core/wasm_ext/wasm/loader.py b/lnbits/core/wasm_ext/wasm/loader.py new file mode 100644 index 000000000..a1f4ea43b --- /dev/null +++ b/lnbits/core/wasm_ext/wasm/loader.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from lnbits.core.wasm_ext.wasm.config import ( + WasmExtensionConfig, + WasmExtensionExport, + parse_wasm_extension_config, +) +from lnbits.settings import settings + + +@dataclass(frozen=True) +class WasmExtension: + id: str + name: str + version: str + root_path: Path + module_path: Path + wit_path: Path | None + world: str + exports: list[WasmExtensionExport] + config: WasmExtensionConfig + + +def is_wasm_extension_id(ext_id: str) -> bool: + ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id) + config = _load_json(ext_dir / "config.json") + return bool(config and config.get("extension_type") == "wasm") + + +def is_wasm_extension_dir(ext_dir: Path) -> bool: + config = _load_json(ext_dir / "config.json") + return bool(config and config.get("extension_type") == "wasm") + + +def load_wasm_extension_config(ext_id: str) -> WasmExtensionConfig | None: + ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id) + config = _load_json(ext_dir / "config.json") + if not config or config.get("extension_type") != "wasm": + return None + return parse_wasm_extension_config(ext_id, config) + + +def load_wasm_extension(ext_id: str) -> WasmExtension: + ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id) + raw_config = _load_json(ext_dir / "config.json") + if not raw_config: + raise FileNotFoundError(f"Missing WASM extension config for '{ext_id}'.") + if raw_config.get("extension_type") != "wasm": + raise ValueError(f"Extension '{ext_id}' is not a WASM extension.") + config = parse_wasm_extension_config(ext_id, raw_config) + + module_path = _extension_path(ext_dir, config.wasm.module) + wit_path = _optional_extension_path(ext_dir, config.wasm.wit) + _check_wasm_module(module_path) + if wit_path and not wit_path.is_file(): + raise FileNotFoundError(f"WIT file not found: {wit_path}") + + return WasmExtension( + id=config.id, + name=config.name, + version=config.version, + root_path=ext_dir, + module_path=module_path, + wit_path=wit_path, + world=config.wasm.world, + exports=config.wasm.exports, + config=config, + ) + + +def _load_json(path: Path) -> dict[str, Any] | None: + if not path.is_file(): + return None + with path.open("r", encoding="utf-8") as config_file: + value = json.load(config_file) + if not isinstance(value, dict): + raise ValueError(f"Expected JSON object in '{path}'.") + return value + + +def _extension_path(ext_dir: Path, value: Any) -> Path: + if not isinstance(value, str) or not value: + raise ValueError(f"Missing relative path for extension '{ext_dir.name}'.") + path = (ext_dir / value).resolve() + if ext_dir.resolve() not in path.parents: + raise ValueError(f"Extension path escapes extension root: {value}") + return path + + +def _optional_extension_path(ext_dir: Path, value: Any) -> Path | None: + if value is None: + return None + return _extension_path(ext_dir, value) + + +def _check_wasm_module(path: Path) -> None: + if not path.is_file(): + raise FileNotFoundError(f"WASM module not found: {path}") + with path.open("rb") as wasm_file: + magic = wasm_file.read(4) + if magic != b"\0asm": + raise ValueError(f"Invalid WASM module: {path}") diff --git a/lnbits/db.py b/lnbits/db.py index 55c98ab9a..e9e926add 100644 --- a/lnbits/db.py +++ b/lnbits/db.py @@ -725,20 +725,20 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901 if get_origin(outertype_) is list: _items = _safe_load_json(value) if isinstance(value, str) else value _dict[key] = [ - dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v + dict_to_submodel(type_, v) if _is_subclass(type_, BaseModel) else v for v in _items ] continue - if issubclass(type_, bool): + if _is_subclass(type_, bool): _dict[key] = bool(value) continue - if issubclass(type_, datetime): + if _is_subclass(type_, datetime): if DB_TYPE == SQLITE: _dict[key] = datetime.fromtimestamp(value, timezone.utc) else: _dict[key] = value.replace(tzinfo=timezone.utc) continue - if issubclass(type_, BaseModel): + if _is_subclass(type_, BaseModel): _dict[key] = dict_to_submodel(type_, value) continue # TODO: remove this when all sub models are migrated to Pydantic @@ -763,6 +763,13 @@ def _safe_load_json(value: str) -> dict: return {} +def _is_subclass(type_: Any, class_or_tuple: type | tuple[type, ...]) -> bool: + try: + return issubclass(type_, class_or_tuple) + except TypeError: + return False + + def _valid_sql_name(name: str) -> bool: """Check if a SQL name is valid (alphanumeric and underscores only)""" return ( diff --git a/lnbits/decorators.py b/lnbits/decorators.py index 1673b860f..a8b82d49c 100644 --- a/lnbits/decorators.py +++ b/lnbits/decorators.py @@ -448,7 +448,7 @@ async def _check_user_access(r: Request, user_id: str, conn: Connection | None = async def _check_user_extension_access( user_id: str, path: str, conn: Connection | None = None ): - ext_id = path_segments(path)[0] + ext_id = _extension_id_from_request_path(path) status = await check_user_extension_access(user_id, ext_id, conn=conn) if not status.success: raise HTTPException( @@ -457,6 +457,15 @@ async def _check_user_extension_access( ) +def _extension_id_from_request_path(path: str) -> str: + segments = path_segments(path) + if len(segments) >= 2 and segments[0] == "ext": + return segments[1] + if len(segments) >= 4 and segments[:3] == ["api", "v1", "ext"]: + return segments[3] + return segments[0] + + async def _get_account_from_token( access_token: str, path: str, method: str, conn: Connection | None = None ) -> Account | None: diff --git a/lnbits/settings.py b/lnbits/settings.py index 5cb54af76..30d8e588d 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -60,6 +60,7 @@ class ExtensionsSettings(LNbitsSettings): lnbits_user_default_extensions: list[str] = Field(default=[]) lnbits_extensions_deactivate_all: bool = Field(default=False) lnbits_extensions_builder_activate_non_admins: bool = Field(default=False) + lnbits_wasm_invocation_retention_days: int = Field(default=7, ge=0) lnbits_extensions_reviews_url: str = Field( default="https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r", description=""" @@ -81,6 +82,33 @@ class ExtensionsSettings(LNbitsSettings): return Path(settings.lnbits_data_folder, "extensions_builder") +class WasmRuntimeLimits(LNbitsSettings): + # 0 disables the limit. Installed WASM extensions may override these defaults. + wasm_runtime_max_memory_bytes: int = Field(default=64 * 1024 * 1024, ge=0) + wasm_runtime_max_execution_ms: int = Field(default=5_000, ge=0) + wasm_runtime_max_fuel: int = Field(default=100_000_000, ge=0) + wasm_runtime_max_response_bytes: int = Field(default=1024 * 1024, ge=0) + wasm_runtime_max_request_bytes: int = Field(default=1024 * 1024, ge=0) + wasm_runtime_max_wasm_stack_bytes: int = Field(default=1024 * 1024, ge=0) + + wasm_runtime_max_table_elements: int = Field(default=10_000, ge=0) + wasm_runtime_max_instances: int = Field(default=8, ge=0) + wasm_runtime_max_tables: int = Field(default=10, ge=0) + wasm_runtime_max_memories: int = Field(default=1, ge=0) + + wasm_runtime_max_concurrent_invocations: int = Field(default=16, ge=0) + wasm_runtime_max_concurrent_invocations_per_extension: int = Field(default=4, ge=0) + wasm_runtime_max_concurrent_invocations_per_user: int = Field(default=4, ge=0) + + wasm_runtime_max_host_calls: int = Field(default=1_000, ge=0) + wasm_runtime_max_http_calls: int = Field(default=20, ge=0) + wasm_runtime_max_storage_calls: int = Field(default=100, ge=0) + wasm_runtime_max_wallet_calls: int = Field(default=20, ge=0) + + wasm_runtime_http_timeout_ms: int = Field(default=5_000, ge=0) + wasm_runtime_max_http_response_bytes: int = Field(default=1024 * 1024, ge=0) + + class ExtensionsInstallSettings(LNbitsSettings): lnbits_extensions_default_install: list[str] = Field(default=[]) # required due to GitHUb rate-limit @@ -1011,6 +1039,7 @@ class AuditSettings(LNbitsSettings): class EditableSettings( UsersSettings, ExtensionsSettings, + WasmRuntimeLimits, ThemesSettings, OpsSettings, AssetSettings, diff --git a/lnbits/static/bundle-components.min.js b/lnbits/static/bundle-components.min.js index e992191e2..888032c8d 100644 --- a/lnbits/static/bundle-components.min.js +++ b/lnbits/static/bundle-components.min.js @@ -1 +1 @@ -window.PageError={template:"#page-error"},window.PageHome={template:"#page-home",data:()=>({lnurl:"",authAction:"login",authMethod:"username-password",usr:"",username:"",reset_key:"",email:"",password:"",passwordRepeat:"",invitationCode:"",walletName:"",signup:!1}),computed:{showClaimLnurl(){return""!==this.lnurl&&this.g.settings.allowRegister&&this.g.settings.authMethods.includes("user-id-only")},formatDescription(){return LNbits.utils.convertMarkdown(this.g.settings.siteDescription)},isAccessTokenExpired(){return this.$q.cookies.get("is_access_token_expired")}},methods:{showLogin(e){this.authAction="login",this.authMethod=e},showRegister(e){this.user="",this.username=null,this.password=null,this.passwordRepeat=null,this.invitationCode=null,this.authAction="register",this.authMethod=e},async register(){try{await LNbits.api.register(this.username,this.email,this.password,this.passwordRepeat,this.invitationCode),this.refreshAuthUser()}catch(e){LNbits.utils.notifyApiError(e)}},async reset(){try{await LNbits.api.reset(this.reset_key,this.password,this.passwordRepeat),this.refreshAuthUser()}catch(e){LNbits.utils.notifyApiError(e)}},async login(){try{await LNbits.api.login(this.username,this.password),this.refreshAuthUser()}catch(e){LNbits.utils.notifyApiError(e)}},async loginUsr(){try{await LNbits.api.loginUsr(this.usr),this.refreshAuthUser()}catch(e){console.warn(e),LNbits.utils.notifyApiError(e)}},async refreshAuthUser(){try{const e=await LNbits.api.getAuthUser();this.g.user=LNbits.map.user(e.data),this.g.isPublicPage=!1,this.$router.push(`/wallet/${this.g.user.wallets[0].id}`)}catch(e){console.warn(e),LNbits.utils.notifyApiError(e)}},createWallet(){LNbits.api.createAccount(this.walletName).then(e=>{this.$router.push(`/wallet/${e.data.id}`)})},processing(){Quasar.Notify.create({timeout:0,message:"Processing...",icon:null})}},created(){if(this.g.isUserAuthorized)return this.refreshAuthUser();const e=new URLSearchParams(window.location.search);this.reset_key=e.get("reset_key"),this.reset_key&&(this.authAction="reset"),e.has("lightning")&&(this.lnurl=e.get("lightning"))}},window.PageExtensionBuilder={template:"#page-extension-builder",data:()=>({step:1,previewStepNames:{2:"settings",3:"owner_data",4:"client_data",5:"public_page"},extensionDataCleanString:"",extensionData:{id:"",name:"",stub_version:"",short_description:"",description:"",public_page:{has_public_page:!0,owner_data_fields:{name:"",description:""},client_data_fields:{public_inputs:[]},action_fields:{generate_action:!0,generate_payment_logic:!1,wallet_id:"",currency:"",amount:"",paid_flag:""}},preview_action:{is_preview_mode:!1,is_settings_preview:!1,is_owner_data_preview:!1,is_client_data_preview:!1,is_public_page_preview:!1},settings_data:{name:"Settings",enabled:!0,type:"user",fields:[]},owner_data:{name:"OwnerData",fields:[]},client_data:{enabled:!0,name:"ClientData",fields:[]}},sampleField:{name:"name",type:"str",label:"Name",hint:"",optional:!0,editable:!0,searchable:!0,sortable:!0},settingsTypes:[{label:"User Settings",value:"user"},{label:"Admin Settings",value:"admin"}],amountSource:[{label:"Client Data",value:"client_data"},{label:"Owner Data",value:"owner_data"}],extensionStubVersions:[]}),watch:{"extensionData.public_page.action_fields.amount_source":function(e,t){t&&e!==t&&(this.extensionData.public_page.action_fields.amount="")}},computed:{paymentActionAmountFields(){const e=this.extensionData.public_page.action_fields.amount_source;return e?"owner_data"===e?[""].concat(this.extensionData.owner_data.fields.filter(e=>"int"===e.type||"float"===e.type).map(e=>e.name)):"client_data"===e?[""].concat(this.extensionData.client_data.fields.filter(e=>"int"===e.type||"float"===e.type).map(e=>e.name)):void 0:[""]}},methods:{saveState(){this.$q.localStorage.set("lnbits.extension.builder.data",JSON.stringify(this.extensionData)),this.$q.localStorage.set("lnbits.extension.builder.step",this.step)},nextStep(){this.saveState(),this.$refs.stepper.next(),this.refreshPreview()},previousStep(){this.saveState(),this.$refs.stepper.previous(),this.refreshPreview()},onStepChange(){this.saveState(),this.refreshPreview()},clearAllData(){LNbits.utils.confirmDialog("Are you sure you want to clear all data? This action cannot be undone.").onOk(()=>{this.extensionData=JSON.parse(this.extensionDataCleanString),this.$q.localStorage.remove("lnbits.extension.builder.data"),this.$refs.stepper.set(1)})},exportJsonData(){!0!==Quasar.exportFile(`${this.extensionData.id||"data-export"}.json`,JSON.stringify(this.extensionData,null,2),"text/json")?Quasar.Notify.create({message:"Browser denied file download...",color:"negative",icon:null}):Quasar.Notify.create({message:"File downloaded!",color:"positive",icon:"file_download"})},onJsonDataInput(e){const t=e.target.files[0],a=new FileReader;a.onload=e=>{this.extensionData={...this.extensionData,...JSON.parse(e.target.result)},this.$refs.extensionDataInput.value=null,Quasar.Notify.create({message:"File loaded!",color:"positive",icon:"file_upload"})},a.readAsText(t)},async buildExtension(){try{const e={responseType:"blob"},t=await LNbits.api.request("POST","/api/v1/extension/builder/zip",null,this.extensionData,e),a=window.URL.createObjectURL(new Blob([t.data])),s=document.createElement("a");s.href=a,s.download=`${this.extensionData.id||"lnbits-extension"}.zip`,document.body.appendChild(s),s.click(),s.remove(),window.URL.revokeObjectURL(a)}catch(e){LNbits.utils.notifyApiError(e)}},async buildExtensionAndDeploy(){try{const{data:e}=await LNbits.api.request("POST","/api/v1/extension/builder/deploy",null,this.extensionData);Quasar.Notify.create({message:e.message||"Extension deployed!",color:"positive"})}catch(e){LNbits.utils.notifyApiError(e)}},async cleanCacheData(){LNbits.utils.confirmDialog("Are you sure you want to clean the cache data? This action cannot be undone.","Clean Cache Data").onOk(async()=>{try{const{data:e}=await LNbits.api.request("DELETE","/api/v1/extension/builder",null,{});Quasar.Notify.create({message:e.message||"Cache data cleaned!",color:"positive"})}catch(e){LNbits.utils.notifyApiError(e)}})},async previewExtension(e){this.saveState();try{await LNbits.api.request("POST","/api/v1/extension/builder/preview",null,{...this.extensionData,preview_action:{is_preview_mode:!!e,is_settings_preview:"settings"===e,is_owner_data_preview:"owner_data"===e,is_client_data_preview:"client_data"===e,is_public_page_preview:"public_page"===e}}),this.refreshIframe(e)}catch(e){LNbits.utils.notifyApiError(e)}},async refreshPreview(){setTimeout(()=>{const e=this.previewStepNames[`${this.step}`]||"";e&&this.previewExtension(e)},100)},async getStubExtensionReleases(){try{const e="extension_builder_stub",{data:t}=await LNbits.api.request("GET",`/api/v1/extension/${e}/releases`);this.extensionStubVersions=t;const a=t.filter(e=>e.is_version_compatible);this.extensionData.stub_version=a[0]?a[0].version:""}catch(e){LNbits.utils.notifyApiError(e)}},refreshIframe(e=""){const t=this.$refs[`iframeStep${this.step}`];if(!t)return void console.warn("Extension Builder Preview iframe not loaded yet.");t.onload=()=>{const e=t.contentDocument||t.contentWindow.document;e.body.style.transform="scale(0.8)",e.body.style.transformOrigin="center top"};let a="Page"+this.extensionData.id.toLowerCase().split("_").map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join("");"public_page"===e&&(a+="Public"),t.src=`/extensions/builder/preview?ext_id=${this.extensionData.id}&page=${e}&component=${a}`},initBasicData(){this.extensionData.owner_data.fields=[JSON.parse(JSON.stringify(this.sampleField))],this.extensionData.client_data.fields=[JSON.parse(JSON.stringify(this.sampleField))],this.extensionData.settings_data.fields=[JSON.parse(JSON.stringify(this.sampleField))],this.extensionDataCleanString=JSON.stringify(this.extensionData)}},created(){this.initBasicData();const e=this.$q.localStorage.getItem("lnbits.extension.builder.data");e&&(this.extensionData={...this.extensionData,...JSON.parse(e)});const t=+this.$q.localStorage.getItem("lnbits.extension.builder.step");t&&(this.step=t),this.g.user.admin&&this.getStubExtensionReleases(),setTimeout(()=>{this.refreshIframe()},1e3)}},window.PageExtensionBuilder={template:"#page-extension-builder",data:()=>({step:1,previewStepNames:{2:"settings",3:"owner_data",4:"client_data",5:"public_page"},extensionDataCleanString:"",extensionData:{id:"",name:"",stub_version:"",short_description:"",description:"",public_page:{has_public_page:!0,owner_data_fields:{name:"",description:""},client_data_fields:{public_inputs:[]},action_fields:{generate_action:!0,generate_payment_logic:!1,wallet_id:"",currency:"",amount:"",paid_flag:""}},preview_action:{is_preview_mode:!1,is_settings_preview:!1,is_owner_data_preview:!1,is_client_data_preview:!1,is_public_page_preview:!1},settings_data:{name:"Settings",enabled:!0,type:"user",fields:[]},owner_data:{name:"OwnerData",fields:[]},client_data:{enabled:!0,name:"ClientData",fields:[]}},sampleField:{name:"name",type:"str",label:"Name",hint:"",optional:!0,editable:!0,searchable:!0,sortable:!0},settingsTypes:[{label:"User Settings",value:"user"},{label:"Admin Settings",value:"admin"}],amountSource:[{label:"Client Data",value:"client_data"},{label:"Owner Data",value:"owner_data"}],extensionStubVersions:[]}),watch:{"extensionData.public_page.action_fields.amount_source":function(e,t){t&&e!==t&&(this.extensionData.public_page.action_fields.amount="")}},computed:{paymentActionAmountFields(){const e=this.extensionData.public_page.action_fields.amount_source;return e?"owner_data"===e?[""].concat(this.extensionData.owner_data.fields.filter(e=>"int"===e.type||"float"===e.type).map(e=>e.name)):"client_data"===e?[""].concat(this.extensionData.client_data.fields.filter(e=>"int"===e.type||"float"===e.type).map(e=>e.name)):void 0:[""]}},methods:{saveState(){this.$q.localStorage.set("lnbits.extension.builder.data",JSON.stringify(this.extensionData)),this.$q.localStorage.set("lnbits.extension.builder.step",this.step)},nextStep(){this.saveState(),this.$refs.stepper.next(),this.refreshPreview()},previousStep(){this.saveState(),this.$refs.stepper.previous(),this.refreshPreview()},onStepChange(){this.saveState(),this.refreshPreview()},clearAllData(){LNbits.utils.confirmDialog("Are you sure you want to clear all data? This action cannot be undone.").onOk(()=>{this.extensionData=JSON.parse(this.extensionDataCleanString),this.$q.localStorage.remove("lnbits.extension.builder.data"),this.$refs.stepper.set(1)})},exportJsonData(){!0!==Quasar.exportFile(`${this.extensionData.id||"data-export"}.json`,JSON.stringify(this.extensionData,null,2),"text/json")?Quasar.Notify.create({message:"Browser denied file download...",color:"negative",icon:null}):Quasar.Notify.create({message:"File downloaded!",color:"positive",icon:"file_download"})},onJsonDataInput(e){const t=e.target.files[0],a=new FileReader;a.onload=e=>{this.extensionData={...this.extensionData,...JSON.parse(e.target.result)},this.$refs.extensionDataInput.value=null,Quasar.Notify.create({message:"File loaded!",color:"positive",icon:"file_upload"})},a.readAsText(t)},async buildExtension(){try{const e={responseType:"blob"},t=await LNbits.api.request("POST","/api/v1/extension/builder/zip",null,this.extensionData,e),a=window.URL.createObjectURL(new Blob([t.data])),s=document.createElement("a");s.href=a,s.download=`${this.extensionData.id||"lnbits-extension"}.zip`,document.body.appendChild(s),s.click(),s.remove(),window.URL.revokeObjectURL(a)}catch(e){LNbits.utils.notifyApiError(e)}},async buildExtensionAndDeploy(){try{const{data:e}=await LNbits.api.request("POST","/api/v1/extension/builder/deploy",null,this.extensionData);Quasar.Notify.create({message:e.message||"Extension deployed!",color:"positive"})}catch(e){LNbits.utils.notifyApiError(e)}},async cleanCacheData(){LNbits.utils.confirmDialog("Are you sure you want to clean the cache data? This action cannot be undone.","Clean Cache Data").onOk(async()=>{try{const{data:e}=await LNbits.api.request("DELETE","/api/v1/extension/builder",null,{});Quasar.Notify.create({message:e.message||"Cache data cleaned!",color:"positive"})}catch(e){LNbits.utils.notifyApiError(e)}})},async previewExtension(e){this.saveState();try{await LNbits.api.request("POST","/api/v1/extension/builder/preview",null,{...this.extensionData,preview_action:{is_preview_mode:!!e,is_settings_preview:"settings"===e,is_owner_data_preview:"owner_data"===e,is_client_data_preview:"client_data"===e,is_public_page_preview:"public_page"===e}}),this.refreshIframe(e)}catch(e){LNbits.utils.notifyApiError(e)}},async refreshPreview(){setTimeout(()=>{const e=this.previewStepNames[`${this.step}`]||"";e&&this.previewExtension(e)},100)},async getStubExtensionReleases(){try{const e="extension_builder_stub",{data:t}=await LNbits.api.request("GET",`/api/v1/extension/${e}/releases`);this.extensionStubVersions=t;const a=t.filter(e=>e.is_version_compatible);this.extensionData.stub_version=a[0]?a[0].version:""}catch(e){LNbits.utils.notifyApiError(e)}},refreshIframe(e=""){const t=this.$refs[`iframeStep${this.step}`];if(!t)return void console.warn("Extension Builder Preview iframe not loaded yet.");t.onload=()=>{const e=t.contentDocument||t.contentWindow.document;e.body.style.transform="scale(0.8)",e.body.style.transformOrigin="center top"};let a="Page"+this.extensionData.id.toLowerCase().split("_").map(e=>e.charAt(0).toUpperCase()+e.slice(1)).join("");"public_page"===e&&(a+="Public"),t.src=`/extensions/builder/preview?ext_id=${this.extensionData.id}&page=${e}&component=${a}`},initBasicData(){this.extensionData.owner_data.fields=[JSON.parse(JSON.stringify(this.sampleField))],this.extensionData.client_data.fields=[JSON.parse(JSON.stringify(this.sampleField))],this.extensionData.settings_data.fields=[JSON.parse(JSON.stringify(this.sampleField))],this.extensionDataCleanString=JSON.stringify(this.extensionData)}},created(){this.initBasicData();const e=this.$q.localStorage.getItem("lnbits.extension.builder.data");e&&(this.extensionData={...this.extensionData,...JSON.parse(e)});const t=+this.$q.localStorage.getItem("lnbits.extension.builder.step");t&&(this.step=t),this.g.user.admin&&this.getStubExtensionReleases(),setTimeout(()=>{this.refreshIframe()},1e3)}},window.PageExtensionBuilderPreview={template:"#page-extension-builder-preview",mixins:[windowMixin],watch:{name:"reload"},data:()=>({extId:"",pageName:"",componentName:null}),methods:{async reload(){await LNbits.utils.loadTemplate(`/extensions/builder/preview/${this.extId}/template?page_name=${this.pageName}`),await LNbits.utils.loadScript(`/extensions/builder/preview/${this.extId}/component?page_name=${this.pageName}`),this._component=window[this.componentName],console.log("LNbits preview reloaded componentName:",this.componentName,!!this._component),this.$forceUpdate()}},async created(){const e=new URLSearchParams(window.location.search);this.extId=e.get("ext_id")||"",this.pageName=e.get("page")||"",this.componentName=e.get("component")||"",await this.reload()},render(){return this._component?Vue.h(this._component):Vue.h("div","Loading...")}},window.PageExtensions={template:"#page-extensions",data(){return{extbuilderEnabled:!1,slide:0,fullscreen:!1,autoplay:!0,searchTerm:"",tab:"installed",manageExtensionTab:"releases",filteredExtensions:[],categories:new Set,updatableExtensions:[],showUninstallDialog:!1,showManageExtensionDialog:!1,showExtensionDetailsDialog:!1,showDropDbDialog:!1,showPayToEnableDialog:!1,showUpdateAllDialog:!1,dropDbExtensionId:"",selectedExtension:null,selectedImage:null,selectedExtensionDetails:null,selectedExtensionRepos:null,selectedRelease:null,uninstallAndDropDb:!1,maxStars:5,paylinkWebsocket:null,searchToggle:!1,reviewsUrl:null,reviewsDialog:{show:!1,extension:null,loading:!1,submitting:!1,form:{name:"",rating:0,comment:""},error:null},reviews:[],reviewsTable:{loading:!1,columns:[{name:"name",align:"left",label:this.$t("Name"),field:"name",sortable:!0},{name:"comment",align:"left",label:this.$t("Comment"),field:"comment"},{name:"created_at",align:"left",label:this.$t("Date"),field:"created_at"},{name:"rating",align:"right",label:"Rating",field:"rating"}],pagination:{rowsPerPage:5,sortBy:"created_at",descending:!0,page:1}},paymentDialog:{show:!1,invoice:"",hash:""}}},watch:{searchTerm(e){this.filterExtensions(e,this.tab)},tab(e){this.filterExtensions(this.searchTerm,e)}},methods:{filterExtensions(e,t){const a=!["installed","all","featured"].includes(t);var s;this.filteredExtensions=this.extensions.filter(e=>"all"!==t||!e.isInstalled).filter(e=>"installed"!==t||e.isInstalled).filter(e=>"installed"!==t||(!!e.isActive||!!this.g.user.admin)).filter(e=>"featured"!==t||e.isFeatured).filter(e=>!a||(e=>e.categories?.includes(t)??!1)(e)).filter((s=e,function(e){return e.name.toLowerCase().includes(s.toLowerCase())||e.shortDescription?.toLowerCase().includes(s.toLowerCase())})).map(e=>({...e,details_link:e.installedRelease?.details_link||e.latestRelease?.details_link}))},async installExtension(e){this.unsubscribeFromPaylinkWs(),this.selectedExtension.inProgress=!0,this.showManageExtensionDialog=!1,e.payment_hash=e.payment_hash||this.getPaylinkHash(e.pay_link),LNbits.api.request("POST","/api/v1/extension",this.g.user.wallets[0].adminkey,{ext_id:this.selectedExtension.id,archive:e.archive,source_repo:e.source_repo,payment_hash:e.payment_hash,version:e.version}).then(t=>{this.selectedExtension.inProgress=!1;const a=this.extensions.find(e=>e.id===this.selectedExtension.id);a.isAvailable=!0,a.isInstalled=!0,a.installedRelease=e,this.toggleExtension(a),a.inProgress=!1,this.selectedExtension=a,this.extensions=this.extensions.concat([]),this.tab="installed"}).catch(e=>{console.warn(e),this.selectedExtension.inProgress=!1,LNbits.utils.notifyApiError(e)})},async uninstallExtension(){this.showManageExtensionDialog=!1,this.showUninstallDialog=!1,this.selectedExtension.inProgress=!0,LNbits.api.request("DELETE",`/api/v1/extension/${this.selectedExtension.id}`,this.g.user.wallets[0].adminkey).then(e=>{const t=this.extensions.find(e=>e.id===this.selectedExtension.id);t.isAvailable=!1,t.isInstalled=!1,t.inProgress=!1,t.installedRelease=null,this.filteredExtensions=this.filteredExtensions.filter(e=>e.id!==t.id),Quasar.Notify.create({type:"positive",message:"Extension uninstalled!"}),this.uninstallAndDropDb&&this.showDropDb()}).catch(e=>{LNbits.utils.notifyApiError(e),extension.inProgress=!1})},async dropExtensionDb(){const e=this.selectedExtension;this.showManageExtensionDialog=!1,this.showDropDbDialog=!1,this.dropDbExtensionId="",e.inProgress=!0,LNbits.api.request("DELETE",`/api/v1/extension/${e.id}/db`,this.g.user.wallets[0].adminkey).then(t=>{e.installedRelease=null,e.inProgress=!1,e.hasDatabaseTables=!1,Quasar.Notify.create({type:"positive",message:"Extension DB deleted!"})}).catch(t=>{LNbits.utils.notifyApiError(t),e.inProgress=!1})},toggleExtension(e){const t=e.isActive?"activate":"deactivate";LNbits.api.request("PUT",`/api/v1/extension/${e.id}/${t}`,this.g.user.wallets[0].adminkey).then(a=>{Quasar.Notify.create({timeout:2e3,type:"positive",message:`Extension '${e.id}' ${t}d!`})}).catch(t=>{LNbits.utils.notifyApiError(t),e.isActive=!1,e.inProgress=!1})},async enableExtensionForUser(e){e.isPaymentRequired?this.showPayToEnable(e):this.enableExtension(e)},async enableExtension(e){LNbits.api.request("PUT",`/api/v1/extension/${e.id}/enable`,this.g.user.wallets[0].adminkey).then(t=>{this.g.user.extensions=this.g.user.extensions.concat([e.id]),Quasar.Notify.create({type:"positive",message:"Extension enabled!"})}).catch(e=>{console.warn(e),LNbits.utils.notifyApiError(e)})},disableExtension(e){LNbits.api.request("PUT",`/api/v1/extension/${e.id}/disable`,this.g.user.wallets[0].adminkey).then(t=>{this.g.user.extensions=this.g.user.extensions.filter(t=>t!==e.id),Quasar.Notify.create({type:"positive",message:"Extension disabled!"})}).catch(e=>{console.warn(error),LNbits.utils.notifyApiError(e)})},showPayToEnable(e){this.selectedExtension=e,this.selectedExtension.payToEnable.paidAmount=e.payToEnable.amount,this.selectedExtension.payToEnable.showQRCode=!1,this.showPayToEnableDialog=!0},updatePayToInstallData(e){LNbits.api.request("PUT",`/api/v1/extension/${e.id}/sell`,this.g.user.wallets[0].adminkey,{required:e.payToEnable.required,amount:e.payToEnable.amount,wallet:e.payToEnable.wallet}).then(e=>{Quasar.Notify.create({type:"positive",message:"Payment info updated!"}),this.showManageExtensionDialog=!1}).catch(t=>{LNbits.utils.notifyApiError(t),e.inProgress=!1})},showUninstall(){this.showManageExtensionDialog=!1,this.showUninstallDialog=!0,this.uninstallAndDropDb=!1},showDropDb(){this.showDropDbDialog=!0},async showManageExtension(e){this.selectedExtension=e,this.selectedRelease=null,this.selectedExtensionRepos=null,this.manageExtensionTab="releases",this.showManageExtensionDialog=!0;try{const{data:t}=await LNbits.api.request("GET",`/api/v1/extension/${e.id}/releases`);this.selectedExtensionRepos=t.reduce((e,t)=>(e[t.source_repo]=e[t.source_repo]||{releases:[],isInstalled:!1,repo:t.repo},t.inProgress=!1,t.error=null,t.loaded=!1,t.isInstalled=this.isInstalledVersion(this.selectedExtension,t),t.isInstalled&&(e[t.source_repo].isInstalled=!0),t.pay_link&&(t.requiresPayment=!0,t.paidAmount=t.cost_sats,t.payment_hash=this.getPaylinkHash(t.pay_link)),e[t.source_repo].releases.push(t),e),{})}catch(t){LNbits.utils.notifyApiError(t),e.inProgress=!1}},async showExtensionDetails(e,t){if(t){this.selectedExtension=this.extensions.find(t=>t.id===e)||this.selectedExtension,this.selectedExtensionDetails=null,this.showExtensionDetailsDialog=!0,this.slide=0,this.fullscreen=!1;try{const{data:a}=await LNbits.api.request("GET",`/api/v1/extension/${e}/details?details_link=${t}`);this.selectedExtensionDetails=a,this.selectedExtensionDetails.description_md=LNbits.utils.convertMarkdown(a.description_md)}catch(e){console.warn(e)}}},async payAndInstall(e){try{this.selectedExtension.inProgress=!0,this.showManageExtensionDialog=!1;const t=await this.requestPaymentForInstall(this.selectedExtension.id,e);this.rememberPaylinkHash(e.pay_link,t.payment_hash);const a=this.g.user.wallets.find(t=>t.id===e.wallet),{data:s}=await LNbits.api.payInvoice(a,t.payment_request);e.payment_hash=s.payment_hash,await this.installExtension(e)}catch(e){console.warn(e),LNbits.utils.notifyApiError(e)}finally{this.selectedExtension.inProgress=!1}},async payAndEnable(e){try{const t=await this.requestPaymentForEnable(e.id,e.payToEnable.paidAmount),a=this.g.user.wallets.find(t=>t.id===e.payToEnable.paymentWallet),{data:s}=await LNbits.api.payInvoice(a,t.payment_request);this.enableExtension(e),this.showPayToEnableDialog=!1}catch(e){console.warn(e),LNbits.utils.notifyApiError(e)}},async showInstallQRCode(e){this.selectedRelease=e;try{const t=await this.requestPaymentForInstall(this.selectedExtension.id,e);this.selectedRelease.paymentRequest=t.payment_request,this.selectedRelease.payment_hash=t.payment_hash,this.selectedRelease=_.clone(this.selectedRelease),this.rememberPaylinkHash(this.selectedRelease.pay_link,this.selectedRelease.payment_hash),this.subscribeToPaylinkWs(this.selectedRelease.pay_link,t.payment_hash)}catch(e){console.warn(e),LNbits.utils.notifyApiError(e)}},async showEnableQRCode(e){try{e.payToEnable.showQRCode=!0,this.selectedExtension=_.clone(e);const t=await this.requestPaymentForEnable(e.id,e.payToEnable.paidAmount);e.payToEnable.paymentRequest=t.payment_request,this.selectedExtension=_.clone(e);const a=new URL(window.location);a.protocol="https:"===a.protocol?"wss":"ws",a.pathname=`/api/v1/ws/${t.payment_hash}`;const s=new WebSocket(a);s.addEventListener("message",async({data:t})=>{!1===JSON.parse(t).pending&&(Quasar.Notify.create({type:"positive",message:"Invoice Paid!"}),this.enableExtension(e),s.close())})}catch(e){console.warn(e),LNbits.utils.notifyApiError(e)}},async requestPaymentForInstall(e,t){const{data:a}=await LNbits.api.request("PUT",`/api/v1/extension/${e}/invoice/install`,null,{ext_id:e,archive:t.archive,source_repo:t.source_repo,cost_sats:t.paidAmount,version:t.version});return a},async requestPaymentForEnable(e,t){const{data:a}=await LNbits.api.request("PUT",`/api/v1/extension/${e}/invoice/enable`,null,{amount:t});return a},clearHangingInvoice(e){this.forgetPaylinkHash(e.pay_link),e.payment_hash=null},rememberPaylinkHash(e,t){this.$q.localStorage.set(`lnbits.extensions.paylink.${e}`,t)},getPaylinkHash(e){return this.$q.localStorage.getItem(`lnbits.extensions.paylink.${e}`)},forgetPaylinkHash(e){this.$q.localStorage.remove(`lnbits.extensions.paylink.${e}`)},subscribeToPaylinkWs(e,t){const a=new URL(`${e}/${t}`);a.protocol="https:"===a.protocol?"wss":"ws",this.paylinkWebsocket=new WebSocket(a),this.paylinkWebsocket.addEventListener("message",async({data:e})=>{JSON.parse(e).paid?(Quasar.Notify.create({type:"positive",message:"Invoice Paid!"}),this.installExtension(this.selectedRelease)):Quasar.Notify.create({type:"warning",message:"Invoice tracking lost!"})})},unsubscribeFromPaylinkWs(){try{this.paylinkWebsocket&&this.paylinkWebsocket.close()}catch(e){console.warn(e)}},hasNewVersion(e){if(e.installedRelease&&e.latestRelease)return e.installedRelease.version!==e.latestRelease.version},isInstalledVersion(e,t){if(e.installedRelease)return e.installedRelease.source_repo===t.source_repo&&e.installedRelease.version===t.version},getReleaseIcon:e=>e.is_version_compatible?e.isInstalled?"download_done":"download":"block",getReleaseIconColor:e=>e.is_version_compatible?e.isInstalled?"text-green":"":"text-red",async getGitHubReleaseDetails(e){if(!e.is_github_release||e.loaded)return;const[t,a]=e.source_repo.split("/");e.inProgress=!0;try{const{data:s}=await LNbits.api.request("GET",`/api/v1/extension/release/${t}/${a}/${e.version}`);e.loaded=!0,e.is_version_compatible=s.is_version_compatible,e.min_lnbits_version=s.min_lnbits_version,e.warning=s.warning}catch(t){console.warn(t),e.error=t,LNbits.utils.notifyApiError(t)}finally{e.inProgress=!1}},async selectAllUpdatableExtensionss(){this.updatableExtensions.forEach(e=>e.selectedForUpdate=!0)},async updateSelectedExtensions(){let e=0;for(const t of this.updatableExtensions)try{if(!t.selectedForUpdate)continue;t.inProgress=!0,await LNbits.api.request("POST","/api/v1/extension",null,{ext_id:t.id,archive:t.latestRelease.archive,source_repo:t.latestRelease.source_repo,payment_hash:t.latestRelease.payment_hash,version:t.latestRelease.version}),e++,t.isAvailable=!0,t.isInstalled=!0,t.isUpgraded=!0,t.inProgress=!1,t.installedRelease=t.latestRelease,t.isActive=!0,this.toggleExtension(t)}catch(e){console.warn(e),Quasar.Notify.create({type:"negative",message:`Failed to update ${t.id}!`})}finally{t.inProgress=!1}Quasar.Notify.create({type:e?"positive":"warning",message:`${e||"No"} extensions updated!`}),this.showUpdateAllDialog=!1},formatAvg(e){const t=Number(e||0);return Math.round(t/2/100*2)/2},async loadReviewStats(){if(this.reviewsUrl)try{const{data:e}=await LNbits.api.request("GET","/api/v1/extension/reviews/tags"),t={};e.forEach(e=>{t[e.tag]=e}),this.extensions.forEach(e=>{e.reviewStats=t[e.id]||null}),this.filterExtensions(this.searchTerm,this.tab)}catch(e){console.warn(e)}else console.info("Extension reviews are not configured")},async openReviews(e){const t=e||(this.selectedExtensionDetails?this.extensions.find(e=>e.id===this.selectedExtensionDetails.id):null);t&&(this.reviewsUrl?(this.reviewsDialog.extension=t,this.selectedExtension=e,this.reviewsDialog.show=!0,await this.getTagReviews()):Quasar.Notify.create({type:"warning",message:this.$t("reviews_url_not_configured")}))},async getTagReviews(e){if(this.reviewsUrl)try{this.reviewsTable.loading=!0;const t=LNbits.utils.prepareFilterQuery(this.reviewsTable,e),{data:a}=await LNbits.api.request("GET",`/api/v1/extension/reviews/${this.selectedExtension.id}?${t}`);this.reviews=a.data,this.reviewsTable.pagination.rowsNumber=a.total}catch(e){LNbits.utils.notifyApiError(e)}finally{this.reviewsTable.loading=!1}else Quasar.Notify.create({type:"warning",message:this.$t("reviews_url_not_configured")})},formatReviewDate(e){if(!e)return"";const t=Number(e);return Number.isNaN(t)?this.utils.formatDate(e):this.utils.formatTimestamp(t)},async submitReview(){if(this.reviewsDialog.extension&&this.reviewsUrl){this.reviewsDialog.submitting=!0;try{const e={tag:this.reviewsDialog.extension.id,name:this.reviewsDialog.form.name,rating:100*this.reviewsDialog.form.rating,comment:this.reviewsDialog.form.comment},{data:t}=await LNbits.api.request("PUT","/api/v1/extension/reviews",null,e);t.payment_request?this.openInvoiceDialog(t.payment_request,t.payment_hash):(Quasar.Notify.create({type:"positive",message:"Review submitted"}),this.resetReviewForm(),await this.getTagReviews(),await this.loadReviewStats())}catch(e){LNbits.utils.notifyApiError(e)}finally{this.reviewsDialog.submitting=!1}}},openInvoiceDialog(e,t){this.paymentDialog.invoice=e,this.paymentDialog.hash=t,this.paymentDialog.show=!0,this.listenForPayment(t)},resetReviewForm(){this.reviewsDialog.form={name:"",rating:0,comment:""},this.paymentDialog={show:!1,invoice:"",hash:""}},listenForPayment(e){try{const t=new URL(this.reviewsUrl);t.protocol="https:"===t.protocol?"wss:":"ws:",t.pathname=`/api/v1/ws/${e}`;const a=new WebSocket(t);a.addEventListener("message",async()=>{Quasar.Notify.create({type:"positive",message:this.$t("reviews_invoice_paid")}),this.paymentDialog.show=!1,this.resetReviewForm(),setTimeout(async()=>{await this.getTagReviews()},1e3),await this.loadReviewStats(),a.close()})}catch(e){console.warn(e)}},async fetchAllExtensions(){try{const{data:e}=await LNbits.api.request("GET","/api/v1/extension/all");return e.forEach(e=>{e.categories?.forEach(e=>this.categories.add(e))}),e}catch(e){return console.warn(e),LNbits.utils.notifyApiError(e),[]}}},async created(){this.extensions=await this.fetchAllExtensions(),this.extbuilderEnabled=this.g.user.admin||this.g.settings.extBuilder,this.reviewsUrl=this.g.settings.extensionsReviewsUrl,0===this.g.user.extensions.length&&(this.tab="all");const e=window.location.hash.replace("#",""),t=this.extensions.find(t=>t.id===e);t&&(this.searchTerm=t.id,t.isInstalled&&(this.tab="installed")),this.updatableExtensions=this.extensions.filter(e=>this.hasNewVersion(e)),await this.loadReviewStats(),this.filterExtensions(this.searchTerm,this.tab)}},window.PageFirstInstall={template:"#page-first-install",data:()=>({loginData:{isPwd:!0,isPwdRepeat:!0,username:"",password:"",passwordRepeat:"",firstInstallToken:""}}),computed:{checkPasswordsMatch(){return this.loginData.password!==this.loginData.passwordRepeat}},methods:{setPassword(){LNbits.api.request("PUT","/api/v1/auth/first_install",null,{username:this.loginData.username,password:this.loginData.password,password_repeat:this.loginData.passwordRepeat,first_install_token:this.loginData.firstInstallToken}).then(async()=>{const e=await LNbits.api.getAuthUser();this.g.user=LNbits.map.user(e.data),this.g.isPublicPage=!1,this.$router.push("/admin")}).catch(this.utils.notifyApiError)}},created(){const e=new URLSearchParams(window.location.search);this.loginData.firstInstallToken=e.get("token")||""}},window.PagePayments={template:"#page-payments",data:()=>({payments:[],dailyChartData:[],searchDate:{from:null,to:null},searchData:{wallet_id:null,payment_hash:null,memo:null,internal_memo:null},statusFilters:{success:!0,pending:!0,failed:!0,incoming:!0,outgoing:!0},chartData:{showPaymentStatus:!0,showPaymentTags:!0,showBalance:!0,showWalletsSize:!1,showBalanceInOut:!1,showPaymentCountInOut:!1},searchOptions:{status:[]},paymentsTable:{columns:[{name:"status",align:"left",label:"Status",field:"status",sortable:!1},{name:"created_at",align:"left",label:"Created At",field:"created_at",sortable:!0},{name:"amount",align:"right",label:"Amount",field:"amount",sortable:!0},{name:"amountFiat",align:"right",label:"Fiat",field:"amountFiat",sortable:!1},{name:"fee_sats",align:"left",label:"Fee",field:"fee_sats",sortable:!0},{name:"tag",align:"left",label:"Tag",field:"tag",sortable:!1},{name:"memo",align:"left",label:"Memo",field:"memo",sortable:!1,max_length:20},{name:"internal_memo",align:"left",label:"Internal Memo",field:"internal_memo",sortable:!1,max_length:20},{name:"wallet_id",align:"left",label:"Wallet (ID)",field:"wallet_id",sortable:!1},{name:"payment_hash",align:"left",label:"Payment Hash",field:"payment_hash",sortable:!1}],pagination:{sortBy:"created_at",rowsPerPage:25,page:1,descending:!0,rowsNumber:10},search:null,hideEmpty:!0,loading:!1},chartsReady:!1,showDetails:!1,paymentDetails:null,lnbitsBalance:0}),async mounted(){this.chartsReady=!0,await this.$nextTick(),this.initCharts(),await this.fetchPayments()},computed:{},methods:{async fetchPayments(e){const t=Object.entries(this.searchData).reduce((e,[t,a])=>a?(e[t]=a,e):e,{});delete t["time[ge]"],delete t["time[le]"],this.searchDate.from&&(t["time[ge]"]=this.searchDate.from+"T00:00:00"),this.searchDate.to&&(t["time[le]"]=this.searchDate.to+"T23:59:59"),this.paymentsTable.filter=t;try{const t=LNbits.utils.prepareFilterQuery(this.paymentsTable,e),{data:a}=await LNbits.api.request("GET",`/api/v1/payments/all/paginated?${t}`);this.paymentsTable.pagination.rowsNumber=a.total,this.payments=a.data.map(e=>(e.extra&&e.extra.tag&&(e.tag=e.extra.tag),e.timeFrom=moment.utc(e.created_at).local().fromNow(),e.outgoing=e.amount<0,e.amount=new Intl.NumberFormat(this.g.locale).format(e.amount/1e3)+" sats",e.extra?.wallet_fiat_amount&&(e.amountFiat=this.formatCurrency(e.extra.wallet_fiat_amount,e.extra.wallet_fiat_currency)),e.extra?.internal_memo&&(e.internal_memo=e.extra.internal_memo),e.fee_sats=new Intl.NumberFormat(this.g.locale).format(e.fee/1e3)+" sats",e))}catch(e){console.error(e),LNbits.utils.notifyApiError(e)}finally{this.updateCharts(e)}},async searchPaymentsBy(e,t){e&&(this.searchData[e]=t),await this.fetchPayments()},clearDateSeach(){this.searchDate={from:null,to:null},delete this.paymentsTable.filter["time[ge]"],delete this.paymentsTable.filter["time[le]"],this.fetchPayments()},searchByDate(){"string"==typeof this.searchDate&&(this.searchDate={from:this.searchDate,to:this.searchDate}),this.searchDate.from&&(this.paymentsTable.filter["time[ge]"]=this.searchDate.from+"T00:00:00"),this.searchDate.to&&(this.paymentsTable.filter["time[le]"]=this.searchDate.to+"T23:59:59"),this.fetchPayments()},handleFilterChanged(){const{success:e,pending:t,failed:a,incoming:s,outgoing:i}=this.statusFilters;delete this.searchData["status[ne]"],delete this.searchData["status[eq]"],e&&t&&a||(e&&t?this.searchData["status[ne]"]="failed":e&&a?this.searchData["status[ne]"]="pending":a&&t?this.searchData["status[ne]"]="success":e?this.searchData["status[eq]"]="success":t?this.searchData["status[eq]"]="pending":a&&(this.searchData["status[eq]"]="failed")),delete this.searchData["amount[ge]"],delete this.searchData["amount[le]"],s&&i||(s?this.searchData["amount[ge]"]="0":i&&(this.searchData["amount[le]"]="0")),this.fetchPayments()},showDetailsToggle(e){return this.paymentDetails=e,this.showDetails=!this.showDetails},formatCurrency(e,t){try{return LNbits.utils.formatCurrency(e,t)}catch(t){return console.error(t),`${e} ???`}},shortify:(e,t=10)=>(valueLength=(e||"").length,valueLength<=t?e:`${e.substring(0,5)}...${e.substring(valueLength-5,valueLength)}`),async updateCharts(e){let t=LNbits.utils.prepareFilterQuery(this.paymentsTable,e);try{const{data:e}=await LNbits.api.request("GET",`/api/v1/payments/stats/count?${t}&count_by=status`);e.sort((e,t)=>e.field-t.field).reverse(),this.searchOptions.status=e.map(e=>e.field),this.paymentsStatusChart.data.datasets[0].data=e.map(e=>e.total),this.paymentsStatusChart.data.labels=[...this.searchOptions.status],this.paymentsStatusChart.update()}catch(e){console.warn(e),LNbits.utils.notifyApiError(e)}try{const{data:e}=await LNbits.api.request("GET",`/api/v1/payments/stats/wallets?${t}`),a=e.map(e=>e.balance/e.payments_count),s=Math.min(...a),i=Math.max(...a),n=e=>Math.floor(3+22*(e-s)/(i-s)),l=this.randomColors(20),o=e.map((e,t)=>({data:[{x:e.payments_count,y:e.balance,r:n(Math.max(e.balance/e.payments_count,5))}],label:e.wallet_name,wallet_id:e.wallet_id,backgroundColor:l[t%100],hoverOffset:4}));this.paymentsWalletsChart.data.datasets=o,this.paymentsWalletsChart.update()}catch(e){console.warn(e),LNbits.utils.notifyApiError(e)}try{const{data:e}=await LNbits.api.request("GET",`/api/v1/payments/stats/count?${t}&count_by=tag`);this.searchOptions.tag=e.map(e=>e.field),this.searchOptions.status.sort(),this.paymentsTagsChart.data.datasets[0].data=e.map(e=>e.total),this.paymentsTagsChart.data.labels=e.map(e=>e.field||"core"),this.paymentsTagsChart.update()}catch(e){console.warn(e),LNbits.utils.notifyApiError(e)}try{const t=Object.entries(this.searchData).reduce((e,[t,a])=>a?(e[t]=a,e):e,{}),a={...this.paymentsTable,filter:t},s=LNbits.utils.prepareFilterQuery(a,e);let{data:i}=await LNbits.api.request("GET",`/api/v1/payments/stats/daily?${s}`);const n=this.searchDate.from+"T00:00:00",l=this.searchDate.to+"T23:59:59";this.lnbitsBalance=i.length?i[i.length-1].balance:0,i=i.filter(e=>this.searchDate.from&&this.searchDate.to?e.date>=n&&e.date<=l:this.searchDate.from?e.date>=n:!this.searchDate.to||e.date<=l),this.paymentsDailyChart.data.datasets=[{label:"Balance",data:i.map(e=>e.balance),pointStyle:!1,borderWidth:2,tension:.7,fill:1},{label:"Fees",data:i.map(e=>e.fee),pointStyle:!1,borderWidth:1,tension:.4,fill:1}],this.paymentsDailyChart.data.labels=i.map(e=>e.date.substring(0,10)),this.paymentsDailyChart.update(),this.paymentsBalanceInOutChart.data.datasets=[{label:"Incoming Payments Balance",data:i.map(e=>e.balance_in)},{label:"Outgoing Payments Balance",data:i.map(e=>e.balance_out)}],this.paymentsBalanceInOutChart.data.labels=i.map(e=>e.date.substring(0,10)),this.paymentsBalanceInOutChart.update(),this.paymentsCountInOutChart.data.datasets=[{label:"Incoming Payments Count",data:i.map(e=>e.count_in)},{label:"Outgoing Payments Count",data:i.map(e=>-e.count_out)}],this.paymentsCountInOutChart.data.labels=i.map(e=>e.date.substring(0,10)),this.paymentsCountInOutChart.update()}catch(e){console.warn(e),LNbits.utils.notifyApiError(e)}},async initCharts(){const e=this.$q.localStorage.getItem("lnbits.payments.chartData")||{};this.chartData={...this.chartData,...e},this.chartsReady?(this.paymentsStatusChart=new Chart(this.$refs.paymentsStatusChart.getContext("2d"),{type:"doughnut",options:{responsive:!0,maintainAspectRatio:!1,plugins:{title:{display:!1}},onClick:(e,t,a)=>{if(t[0]){const e=t[0].index;this.searchPaymentsBy("status",a.data.labels[e])}}},data:{datasets:[{label:"",data:[],backgroundColor:["rgb(0, 205, 86)","rgb(64, 72, 78)","rgb(255, 99, 132)"],hoverOffset:4}]}}),this.paymentsWalletsChart=new Chart(this.$refs.paymentsWalletsChart.getContext("2d"),{type:"bubble",options:{responsive:!0,maintainAspectRatio:!1,plugins:{legend:{display:!1},title:{display:!1}},onClick:(e,t,a)=>{if(t[0]){const e=t[0].datasetIndex;this.searchPaymentsBy("wallet_id",a.data.datasets[e].wallet_id)}}},data:{datasets:[{label:"",data:[],backgroundColor:this.randomColors(20),hoverOffset:4}]}}),this.paymentsTagsChart=new Chart(this.$refs.paymentsTagsChart.getContext("2d"),{type:"pie",options:{responsive:!0,maintainAspectRatio:!1,plugins:{title:{display:!1},legend:{display:!1,title:{display:!1,text:"Tags"}}},onClick:(e,t,a)=>{if(t[0]){const e=t[0].index;this.searchPaymentsBy("tag",a.data.labels[e])}}},data:{datasets:[{label:"",data:[],backgroundColor:this.randomColors(10),hoverOffset:4}]}}),this.paymentsDailyChart=new Chart(this.$refs.paymentsDailyChart.getContext("2d"),{type:"line",options:{responsive:!0,maintainAspectRatio:!1,plugins:{title:{display:!1},legend:{display:!0,title:{display:!1,text:"Tags"}}}},data:{datasets:[{label:"",data:[],backgroundColor:this.randomColors(10),hoverOffset:4}]}}),this.paymentsBalanceInOutChart=new Chart(this.$refs.paymentsBalanceInOutChart.getContext("2d"),{type:"bar",options:{responsive:!0,maintainAspectRatio:!1,plugins:{title:{display:!1},legend:{display:!0,title:{display:!1,text:"Tags"}}},scales:{x:{stacked:!0},y:{stacked:!0}}},data:{datasets:[{label:"",data:[],backgroundColor:this.randomColors(50),hoverOffset:4}]}}),this.paymentsCountInOutChart=new Chart(this.$refs.paymentsCountInOutChart.getContext("2d"),{type:"bar",options:{responsive:!0,maintainAspectRatio:!1,plugins:{title:{display:!1},legend:{display:!0,title:{display:!1,text:""}}},scales:{x:{stacked:!0},y:{stacked:!0}}},data:{datasets:[{label:"",data:[],backgroundColor:this.randomColors(80),hoverOffset:4}]}})):console.warn("Charts are not ready yet. Initialization delayed.")},saveChartsPreferences(){this.$q.localStorage.set("lnbits.payments.chartData",this.chartData)},randomColors(e=1){const t=[];for(let a=1;a<=10;a++)for(let s=1;s<=10;s++)t.push(`rgb(${s*e*33%200}, ${71*(a+s+e)%255}, ${(a+30*e)%255})`);return t}}},window.PageNode={template:"#page-node",config:{globalProperties:{LNbits:LNbits,msg:"hello"}},data(){return{isSuperUser:!1,wallet:{},tab:"dashboard",payments:1e3,info:{},channel_stats:{},channels:{data:[],filter:""},activeBalance:{},ranks:{},peers:{data:[],filter:""},connectPeerDialog:{show:!1,data:{}},setFeeDialog:{show:!1,data:{fee_ppm:0,fee_base_msat:0}},openChannelDialog:{show:!1,data:{}},closeChannelDialog:{show:!1,data:{}},nodeInfoDialog:{show:!1,data:{}},transactionDetailsDialog:{show:!1,data:{}},states:[{label:"Active",value:"active",color:"green"},{label:"Pending",value:"pending",color:"orange"},{label:"Inactive",value:"inactive",color:"grey"},{label:"Closed",value:"closed",color:"red"}],stateFilters:[{label:"Active",value:"active"},{label:"Pending",value:"pending"}],paymentsTable:{data:[],columns:[{name:"pending",label:""},{name:"date",align:"left",label:this.$t("date"),field:"date",sortable:!0},{name:"sat",align:"right",label:this.$t("amount"),field:e=>this.formatMsat(e.amount),sortable:!0},{name:"fee",align:"right",label:this.$t("fee"),field:"fee"},{name:"destination",align:"right",label:"Destination",field:"destination"},{name:"memo",align:"left",label:this.$t("memo"),field:"memo"}],pagination:{rowsPerPage:10,page:1,rowsNumber:10},filter:null},invoiceTable:{data:[],columns:[{name:"pending",label:""},{name:"paid_at",field:"paid_at",align:"left",label:"Paid at",sortable:!0},{name:"expiry",label:this.$t("expiry"),field:"expiry",align:"left",sortable:!0},{name:"amount",label:this.$t("amount"),field:e=>this.formatMsat(e.amount),sortable:!0},{name:"memo",align:"left",label:this.$t("memo"),field:"memo"}],pagination:{rowsPerPage:10,page:1,rowsNumber:10},filter:null}}},created(){this.getInfo(),this.get1MLStats()},watch:{tab(e){"transactions"!==e||this.paymentsTable.data.length?"channels"!==e||this.channels.data.length||(this.getChannels(),this.getPeers()):(this.getPayments(),this.getInvoices())}},computed:{checkChanges(){return!_.isEqual(this.settings,this.formData)},filteredChannels(){return this.stateFilters?this.channels.data.filter(e=>this.stateFilters.find(({value:t})=>t==e.state)):this.channels.data},totalBalance(){return this.filteredChannels.reduce((e,t)=>(e.local_msat+=t.balance.local_msat,e.remote_msat+=t.balance.remote_msat,e.total_msat+=t.balance.total_msat,e),{local_msat:0,remote_msat:0,total_msat:0})}},methods:{formatMsat:e=>LNbits.utils.formatMsat(e),nodeApi(e,t,a){const s=new URLSearchParams(a?.query);return LNbits.api.request(e,`/node/api/v1${t}?${s}`,{},a?.data).catch(e=>{LNbits.utils.notifyApiError(e)})},getChannel(e){return this.nodeApi("GET",`/channels/${e}`).then(e=>{this.setFeeDialog.data.fee_ppm=e.data.fee_ppm,this.setFeeDialog.data.fee_base_msat=e.data.fee_base_msat})},getChannels(){return this.nodeApi("GET","/channels").then(e=>{this.channels.data=e.data})},getInfo(){return this.nodeApi("GET","/info").then(e=>{this.info=e.data,this.channel_stats=e.data.channel_stats}).catch(()=>{this.info={},this.channel_stats={}})},get1MLStats(){return this.nodeApi("GET","/rank").then(e=>{this.ranks=e.data}).catch(()=>{this.ranks={}})},getPayments(e){e&&(this.paymentsTable.pagination=e.pagination);let t=this.paymentsTable.pagination;const a={limit:t.rowsPerPage,offset:(t.page-1)*t.rowsPerPage??0};return this.nodeApi("GET","/payments",{query:a}).then(e=>{this.paymentsTable.data=e.data.data,this.paymentsTable.pagination.rowsNumber=e.data.total})},getInvoices(e){e&&(this.invoiceTable.pagination=e.pagination);let t=this.invoiceTable.pagination;const a={limit:t.rowsPerPage,offset:(t.page-1)*t.rowsPerPage??0};return this.nodeApi("GET","/invoices",{query:a}).then(e=>{this.invoiceTable.data=e.data.data,this.invoiceTable.pagination.rowsNumber=e.data.total})},getPeers(){return this.nodeApi("GET","/peers").then(e=>{this.peers.data=e.data})},connectPeer(){this.nodeApi("POST","/peers",{data:this.connectPeerDialog.data}).then(()=>{this.connectPeerDialog.show=!1,this.getPeers()})},disconnectPeer(e){LNbits.utils.confirmDialog("Do you really wanna disconnect this peer?").onOk(()=>{this.nodeApi("DELETE",`/peers/${e}`).then(e=>{Quasar.Notify.create({message:"Disconnected",icon:null}),this.needsRestart=!0,this.getPeers()})})},setChannelFee(e){this.nodeApi("PUT",`/channels/${e}`,{data:this.setFeeDialog.data}).then(e=>{this.setFeeDialog.show=!1,this.getChannels()}).catch(LNbits.utils.notifyApiError)},openChannel(){this.nodeApi("POST","/channels",{data:this.openChannelDialog.data}).then(e=>{this.openChannelDialog.show=!1,this.getChannels()}).catch(e=>{console.log(e)})},showCloseChannelDialog(e){this.closeChannelDialog.show=!0,this.closeChannelDialog.data={force:!1,short_id:e.short_id,...e.point}},closeChannel(){this.nodeApi("DELETE","/channels",{query:this.closeChannelDialog.data}).then(e=>{this.closeChannelDialog.show=!1,this.getChannels()})},showSetFeeDialog(e){this.setFeeDialog.show=!0,this.setFeeDialog.channel_id=e,this.getChannel(e)},showOpenChannelDialog(e){this.openChannelDialog.show=!0,this.openChannelDialog.data={peer_id:e,funding_amount:0}},showNodeInfoDialog(e){this.nodeInfoDialog.show=!0,this.nodeInfoDialog.data=e},showTransactionDetailsDialog(e){this.transactionDetailsDialog.show=!0,this.transactionDetailsDialog.data=e},shortenNodeId:e=>e?e.substring(0,5)+"..."+e.substring(e.length-5):"..."}},window.PageNodePublic={template:"#page-node-public",data:()=>({enabled:!1,isSuperUser:!1,wallet:{},tab:"dashboard",payments:1e3,info:{},channel_stats:{},channels:[],activeBalance:{},ranks:{},peers:[],connectPeerDialog:{show:!1,data:{}},openChannelDialog:{show:!1,data:{}},closeChannelDialog:{show:!1,data:{}},nodeInfoDialog:{show:!1,data:{}},states:[{label:"Active",value:"active",color:"green"},{label:"Pending",value:"pending",color:"orange"},{label:"Inactive",value:"inactive",color:"grey"},{label:"Closed",value:"closed",color:"red"}]}),created(){this.getInfo(),this.get1MLStats()},methods:{formatMsat:e=>LNbits.utils.formatMsat(e),api:(e,t,a)=>LNbits.api.request(e,"/node/public/api/v1"+t,{},a),getInfo(){this.api("GET","/info",{}).then(e=>{this.info=e.data,this.channel_stats=e.data.channel_stats,this.enabled=!0}).catch(()=>{this.info={},this.channel_stats={}})},get1MLStats(){this.api("GET","/rank",{}).then(e=>{this.ranks=e.data}).catch(()=>{this.ranks={}})}}},window.PageAudit={template:"#page-audit",data:()=>({chartsReady:!1,auditEntries:[],searchData:{user_id:"",ip_address:"",request_type:"",component:"",request_method:"",response_code:"",path:""},searchOptions:{component:[],request_method:[],response_code:[]},auditTable:{columns:[{name:"created_at",align:"center",label:"Date",field:"created_at",sortable:!0},{name:"duration",align:"left",label:"Duration (sec)",field:"duration",sortable:!0},{name:"component",align:"left",label:"Component",field:"component",sortable:!1},{name:"request_method",align:"left",label:"Method",field:"request_method",sortable:!1},{name:"response_code",align:"left",label:"Code",field:"response_code",sortable:!1},{name:"user_id",align:"left",label:"User Id",field:"user_id",sortable:!1},{name:"ip_address",align:"left",label:"IP Address",field:"ip_address",sortable:!1},{name:"path",align:"left",label:"Path",field:"path",sortable:!1}],pagination:{sortBy:"created_at",rowsPerPage:10,page:1,descending:!0,rowsNumber:10},search:null,hideEmpty:!0,loading:!1},auditDetailsDialog:{data:null,show:!1}}),async created(){},async mounted(){this.chartsReady=!0,await this.$nextTick(),this.initCharts(),await this.fetchAudit()},methods:{async fetchAudit(e){try{const t=LNbits.utils.prepareFilterQuery(this.auditTable,e),{data:a}=await LNbits.api.request("GET",`/audit/api/v1?${t}`);this.auditTable.pagination.rowsNumber=a.total,this.auditEntries=a.data,await this.fetchAuditStats(e)}catch(e){console.warn(e),LNbits.utils.notifyApiError(e)}finally{this.auditTable.loading=!1}},async fetchAuditStats(e){try{const t=LNbits.utils.prepareFilterQuery(this.auditTable,e),{data:a}=await LNbits.api.request("GET",`/audit/api/v1/stats?${t}`),s=a.request_method.map(e=>e.field);this.searchOptions.request_method=[...new Set(this.searchOptions.request_method.concat(s))],this.requestMethodChart.data.labels=s,this.requestMethodChart.data.datasets[0].data=a.request_method.map(e=>e.total),this.requestMethodChart.update();const i=a.response_code.map(e=>e.field);this.searchOptions.response_code=[...new Set(this.searchOptions.response_code.concat(i))],this.responseCodeChart.data.labels=i,this.responseCodeChart.data.datasets[0].data=a.response_code.map(e=>e.total),this.responseCodeChart.update();const n=a.component.map(e=>e.field);this.searchOptions.component=[...new Set(this.searchOptions.component.concat(n))],this.componentUseChart.data.labels=n,this.componentUseChart.data.datasets[0].data=a.component.map(e=>e.total),this.componentUseChart.update(),this.longDurationChart.data.labels=a.long_duration.map(e=>e.field),this.longDurationChart.data.datasets[0].data=a.long_duration.map(e=>e.total),this.longDurationChart.update()}catch(e){console.warn(e),LNbits.utils.notifyApiError(e)}},async searchAuditBy(e,t){e&&(this.searchData[e]=t),this.auditTable.filter=Object.entries(this.searchData).reduce((e,[t,a])=>a?(e[t]=a,e):e,{}),await this.fetchAudit()},showDetailsDialog(e){const t=JSON.parse(e?.request_details||"");try{t.body&&(t.body=JSON.parse(t.body))}catch(e){}this.auditDetailsDialog.data=JSON.stringify(t,null,4),this.auditDetailsDialog.show=!0},shortify:e=>(valueLength=(e||"").length,valueLength<=10?e:`${e.substring(0,5)}...${e.substring(valueLength-5,valueLength)}`),async initCharts(){this.chartsReady?(this.responseCodeChart=new Chart(this.$refs.responseCodeChart.getContext("2d"),{type:"doughnut",options:{responsive:!0,plugins:{legend:{position:"bottom"},title:{display:!1,text:"HTTP Response Codes"}},onClick:(e,t,a)=>{if(t[0]){const e=t[0].index;this.searchAuditBy("response_code",a.data.labels[e])}}},data:{datasets:[{label:"",data:[20,10],backgroundColor:["rgb(100, 99, 200)","rgb(54, 162, 235)","rgb(255, 205, 86)","rgb(255, 5, 86)","rgb(25, 205, 86)","rgb(255, 205, 250)"]}],labels:[]}}),this.requestMethodChart=new Chart(this.$refs.requestMethodChart.getContext("2d"),{type:"bar",options:{responsive:!0,maintainAspectRatio:!1,plugins:{title:{display:!1}},onClick:(e,t,a)=>{if(t[0]){const e=t[0].index;this.searchAuditBy("request_method",a.data.labels[e])}}},data:{datasets:[{label:"",data:[],backgroundColor:["rgb(255, 99, 132)","rgb(54, 162, 235)","rgb(255, 205, 86)","rgb(255, 5, 86)","rgb(25, 205, 86)","rgb(255, 205, 250)"],hoverOffset:4}]}}),this.componentUseChart=new Chart(this.$refs.componentUseChart.getContext("2d"),{type:"pie",options:{responsive:!0,plugins:{legend:{position:"xxx"},title:{display:!1,text:"Components"}},onClick:(e,t,a)=>{if(t[0]){const e=t[0].index;this.searchAuditBy("component",a.data.labels[e])}}},data:{datasets:[{data:[],backgroundColor:["rgb(255, 99, 132)","rgb(54, 162, 235)","rgb(255, 205, 86)","rgb(255, 5, 86)","rgb(25, 205, 86)","rgb(255, 205, 250)","rgb(100, 205, 250)","rgb(120, 205, 250)","rgb(140, 205, 250)","rgb(160, 205, 250)"],hoverOffset:4}]}}),this.longDurationChart=new Chart(this.$refs.longDurationChart.getContext("2d"),{type:"bar",options:{responsive:!0,indexAxis:"y",maintainAspectRatio:!1,plugins:{legend:{title:{display:!1,text:"Long Duration"}}},onClick:(e,t,a)=>{if(t[0]){const e=t[0].index;this.searchAuditBy("path",a.data.labels[e])}}},data:{datasets:[{label:"",data:[],backgroundColor:["rgb(255, 99, 132)","rgb(54, 162, 235)","rgb(255, 205, 86)","rgb(255, 5, 86)","rgb(25, 205, 86)","rgb(255, 205, 250)","rgb(100, 205, 250)","rgb(120, 205, 250)","rgb(140, 205, 250)","rgb(160, 205, 250)"],hoverOffset:4}]}})):console.warn("Charts are not ready yet. Initialization delayed.")}}},window.PageWallet={template:"#page-wallet",data:()=>({parse:{show:!1,invoice:null,lnurlpay:null,lnurlauth:null,data:{request:"",amount:0,comment:"",internalMemo:null,unit:"sat"},paymentChecker:null,copy:{show:!1},camera:{show:!1,camera:"auto"}},receive:{show:!1,status:"pending",paymentReq:null,paymentHash:null,amountMsat:null,minMax:[0,21e14],lnurl:null,units:[],unit:"sat",fiatProvider:"",data:{amount:null,memo:"",internalMemo:null,payment_hash:null}},update:{name:null,currency:null},hasNfc:!1,nfcReaderAbortController:null,formattedFiatAmount:0,paymentFilter:{"status[ne]":"failed"},chartConfig:Quasar.LocalStorage.getItem("lnbits.wallets.chartConfig")||{showPaymentInOutChart:!0,showBalanceChart:!0,showBalanceInOutChart:!0}}),computed:{canPay(){return!!this.parse.invoice&&(this.parse.invoice.expired?(Quasar.Notify.create({message:"Invoice has expired",color:"negative"}),!1):this.parse.invoice.sat<=this.g.wallet.sat)},formattedAmount(){return"sat"==this.receive.unit&&this.g.isSatsDenomination?LNbits.utils.formatMsat(this.receive.amountMsat)+" sat":LNbits.utils.formatCurrency(Number(this.receive.data.amount).toFixed(2),this.g.isSatsDenomination?this.receive.unit:this.g.denomination)},formattedSatAmount(){return LNbits.utils.formatMsat(this.receive.amountMsat)+" sat"}},methods:{handleSendLnurl(e){this.parse.data.request=e,this.parse.show=!0,this.lnurlScan()},msatoshiFormat:e=>LNbits.utils.formatSat(e/1e3),showReceiveDialog(){this.receive.show=!0,this.receive.status="pending",this.receive.paymentReq=null,this.receive.paymentHash=null,this.receive.data.amount=null,this.receive.data.memo=null,this.receive.data.internalMemo=null,this.receive.data.payment_hash=null,this.receive.units=["sat",...this.g.allowedCurrencies.length>0?this.g.allowedCurrencies:this.g.currencies],this.receive.unit=this.g.isFiatPriority&&this.g.wallet.currency||"sat",this.receive.minMax=[0,21e14],this.receive.lnurl=null},onReceiveDialogHide(){this.hasNfc&&this.nfcReaderAbortController.abort()},showParseDialog(){this.parse.show=!0,this.parse.invoice=null,this.parse.lnurlpay=null,this.parse.lnurlauth=null,this.parse.copy.show=window.isSecureContext&&void 0!==navigator.clipboard?.readText,this.parse.data.request="",this.parse.data.comment="",this.parse.data.internalMemo=null,this.parse.data.paymentChecker=null,this.parse.camera.show=!1},closeParseDialog(){setTimeout(()=>{clearInterval(this.parse.paymentChecker)},1e4)},handleBalanceUpdate(e){this.g.wallet.sat=this.g.wallet.sat+e},createInvoice(){this.receive.status="loading",this.g.isSatsDenomination||(this.receive.data.amount=100*this.receive.data.amount),LNbits.api.createInvoice(this.g.wallet,this.receive.data.amount,this.receive.data.memo,this.receive.unit,this.receive.lnurlWithdraw,this.receive.fiatProvider,this.receive.data.internalMemo,this.receive.data.payment_hash).then(e=>{if(this.g.updatePayments=!this.g.updatePayments,this.receive.status="success",this.receive.paymentReq=e.data.bolt11,this.receive.fiatPaymentReq=e.data.extra?.fiat_payment_request,this.receive.amountMsat=e.data.amount,this.receive.paymentHash=e.data.payment_hash,this.receive.lnurl||this.readNfcTag(),this.receive.lnurl&&null!==e.data.extra?.lnurl_response){!1===e.data.extra.lnurl_response&&(e.data.extra.lnurl_response="Unable to connect");const t=this.receive.lnurl.callback.split("/")[2];if("string"==typeof e.data.extra.lnurl_response)return void Quasar.Notify.create({timeout:5e3,type:"warning",message:`${t} lnurl-withdraw call failed.`,caption:e.data.extra.lnurl_response});!0===e.data.extra.lnurl_response&&Quasar.Notify.create({timeout:3e3,message:`Invoice sent to ${t}!`,spinner:!0})}}).catch(e=>{LNbits.utils.notifyApiError(e),this.receive.status="pending"})},lnurlScan(){LNbits.api.request("POST","/api/v1/lnurlscan",this.g.wallet.adminkey,{lnurl:this.parse.data.request}).then(e=>{const t=e.data;if("ERROR"!==t.status){if("payRequest"===t.tag)this.parse.lnurlpay=Object.freeze(t),this.parse.data.amount=t.minSendable/1e3,this.receive.units=["sats",...this.g.allowedCurrencies.length>0?this.g.allowedCurrencies:this.g.currencies];else if("login"===t.tag)this.parse.lnurlauth=Object.freeze(t);else if("withdrawRequest"===t.tag){this.parse.show=!1,this.receive.show=!0,this.receive.lnurlWithdraw=Object.freeze(t),this.receive.status="pending",this.receive.paymentReq=null,this.receive.paymentHash=null,this.receive.data.amount=t.maxWithdrawable/1e3,this.receive.data.memo=t.defaultDescription,this.receive.minMax=[t.minWithdrawable/1e3,t.maxWithdrawable/1e3];const e=t.callback.split("/")[2];this.receive.lnurl={domain:e,callback:t.callback,fixed:t.fixed}}}else Quasar.Notify.create({timeout:5e3,type:"warning",message:"lnurl scan failed.",caption:t.reason})}).catch(e=>{LNbits.utils.notifyApiError(e)})},decodeQR(e){this.parse.data.request=e,this.decodeRequest(),this.parse.camera.show=!1},isLnurl:e=>e.toLowerCase().startsWith("lnurl1")||e.startsWith("lnurlp://")||e.startsWith("lnurlw://")||e.startsWith("lnurlauth://")||e.match(/[\w.+-~_]+@[\w.+-~_]/),decodeRequest(){this.parse.show=!0,this.parse.data.request=this.parse.data.request.trim();const e=this.parse.data.request.toLowerCase();if(e.startsWith("lightning:")?this.parse.data.request=this.parse.data.request.slice(10):e.startsWith("lnurl:")?this.parse.data.request=this.parse.data.request.slice(6):e.includes("lightning=lnurl1")&&(this.parse.data.request=this.parse.data.request.split("lightning=")[1].split("&")[0]),this.isLnurl(this.parse.data.request))return void this.lnurlScan();let t;this.parse.data.request.toLowerCase().includes("lightning")&&(this.parse.data.request=this.parse.data.request.split("lightning=")[1],this.parse.data.request.includes("&")&&(this.parse.data.request=this.parse.data.request.split("&")[0]));try{t=decode(this.parse.data.request)}catch(e){return Quasar.Notify.create({timeout:3e3,type:"warning",message:e+".",caption:"400 BAD REQUEST"}),void(this.parse.show=!1)}let a={msat:t.human_readable_part.amount,sat:t.human_readable_part.amount/1e3,fsat:LNbits.utils.formatSat(t.human_readable_part.amount/1e3),bolt11:this.parse.data.request};_.each(t.data.tags,e=>{if(_.isObject(e)&&_.has(e,"description"))if("payment_hash"===e.description)a.hash=e.value;else if("description"===e.description)a.description=e.value;else if("expiry"===e.description){const s=new Date(1e3*(t.data.time_stamp+e.value)),i=new Date(1e3*t.data.time_stamp);a.expireDate=Quasar.date.formatDate(s,"YYYY-MM-DDTHH:mm:ss.SSSZ"),a.createdDate=Quasar.date.formatDate(i,"YYYY-MM-DDTHH:mm:ss.SSSZ"),a.expireDateFrom=moment.utc(s).local().fromNow(),a.createdDateFrom=moment.utc(i).local().fromNow(),a.expired=!1}}),this.g.wallet.currency&&(a.fiatAmount=LNbits.utils.formatCurrency((a.sat/1e8*this.g.exchangeRate).toFixed(2),this.g.wallet.currency)),this.parse.invoice=Object.freeze(a)},payInvoice(){const e=Quasar.Notify.create({timeout:0,message:this.$t("payment_processing")});LNbits.api.payInvoice(this.g.wallet,this.parse.data.request,this.parse.data.internalMemo).then(t=>{e(),this.g.updatePayments=!this.g.updatePayments,this.parse.show=!1,"success"==t.data.status&&Quasar.Notify.create({type:"positive",message:this.$t("payment_successful")}),"pending"==t.data.status&&Quasar.Notify.create({type:"info",message:this.$t("payment_pending")})}).catch(t=>{e(),LNbits.utils.notifyApiError(t),this.g.updatePayments=!this.g.updatePayments,this.parse.show=!1})},payLnurl(){LNbits.api.request("post","/api/v1/payments/lnurl",this.g.wallet.adminkey,{res:this.parse.lnurlpay,lnurl:this.parse.data.request,unit:this.parse.data.unit,amount:1e3*this.parse.data.amount,comment:this.parse.data.comment,internalMemo:this.parse.data.internalMemo}).then(e=>{if(this.parse.show=!1,e.data.extra.success_action){const t=JSON.parse(e.data.extra.success_action);switch(t.tag){case"url":Quasar.Notify.create({message:t.url,caption:t.description,html:!1,type:"positive",timeout:0,closeBtn:!0,actions:[{label:"Open link",color:"white",handler:()=>this.utils.openUrlInNewTab(t.url)}]});break;case"message":Quasar.Notify.create({message:t.message,type:"positive",timeout:0,closeBtn:!0});break;case"aes":this.utils.decryptLnurlPayAES(t,e.data.preimage).then(e=>{Quasar.Notify.create({message:e,caption:t.description,html:!1,type:"positive",timeout:0,closeBtn:!0})}).catch(e=>{Quasar.Notify.create({message:t.description||"Payment successful.",caption:"Could not decrypt success action.",html:!1,type:"warning",timeout:0,closeBtn:!0})})}}}).catch(LNbits.utils.notifyApiError)},authLnurl(){const e=Quasar.Notify.create({timeout:10,message:"Performing authentication..."});LNbits.api.request("post","/api/v1/lnurlauth",wallet.adminkey,this.parse.lnurlauth).then(t=>{e(),Quasar.Notify.create({message:"Authentication successful.",type:"positive",timeout:3500}),this.parse.show=!1}).catch(e=>{e.response.data.reason?Quasar.Notify.create({message:`Authentication failed. ${this.parse.lnurlauth.callback} says:`,caption:e.response.data.reason,type:"warning",timeout:5e3}):LNbits.utils.notifyApiError(e)})},updateWallet(e){LNbits.api.request("PATCH","/api/v1/wallet",this.g.wallet.adminkey,e).then(e=>{this.g.wallet={...this.g.wallet,...e.data};const t=this.g.user.wallets.findIndex(t=>t.id===e.data.id);-1!==t&&(this.g.user.wallets[t]={...this.g.user.wallets[t],...e.data}),Quasar.Notify.create({message:"Wallet updated.",type:"positive",timeout:3500})}).catch(e=>{LNbits.utils.notifyApiError(e)})},pasteToTextArea(){this.$refs.textArea.focus(),navigator.clipboard.readText().then(e=>{this.parse.data.request=e.trim()})},readNfcTag(){try{if("undefined"==typeof NDEFReader)return void console.debug("NFC not supported on this device or browser.");const e=new NDEFReader;this.nfcReaderAbortController=new AbortController,this.nfcReaderAbortController.signal.onabort=e=>{console.debug("All NFC Read operations have been aborted.")},this.hasNfc=!0;const t=Quasar.Notify.create({message:"Tap your NFC tag to pay this invoice with LNURLw."});return e.scan({signal:this.nfcReaderAbortController.signal}).then(()=>{e.onreadingerror=()=>{Quasar.Notify.create({type:"negative",message:"There was an error reading this NFC tag."})},e.onreading=({message:e})=>{const a=new TextDecoder("utf-8"),s=e.records.find(e=>-1!==a.decode(e.data).toUpperCase().indexOf("LNURLW"));if(s){t(),Quasar.Notify.create({type:"positive",message:"NFC tag read successfully."});const e=a.decode(s.data);this.payInvoiceWithNfc(e)}else Quasar.Notify.create({type:"warning",message:"NFC tag does not have LNURLw record."})}})}catch(e){Quasar.Notify.create({type:"negative",message:e?e.toString():"An unexpected error has occurred."})}},payInvoiceWithNfc(e){const t=Quasar.Notify.create({timeout:0,spinner:!0,message:this.$t("processing_payment")});LNbits.api.request("POST",`/api/v1/payments/${this.receive.paymentReq}/pay-with-nfc`,this.g.wallet.adminkey,{lnurl_w:e}).then(e=>{t(),e.data.success?Quasar.Notify.create({type:"positive",message:"Payment successful"}):Quasar.Notify.create({type:"negative",message:e.data.detail||"Payment failed"})}).catch(e=>{t(),LNbits.utils.notifyApiError(e)})}},created(){const e=new URLSearchParams(window.location.search);(e.has("lightning")||e.has("lnurl"))&&(this.parse.data.request=e.get("lightning")||e.get("lnurl"),this.decodeRequest(),this.parse.show=!0);const t=this.g.user.wallets.find(e=>e.id===this.$route.params.id);t?(this.g.wallet=t,this.g.lastActiveWallet=t.id,this.$q.localStorage.setItem("lnbits.lastActiveWallet",t.id),this.$router.replace(`/wallet/${t.id}`)):(this.g.errorCode=404,this.g.errorMessage="Wallet not found.",this.$router.push("/error"))},watch:{"g.updatePaymentsHash"(){this.receive.show=!1},"g.updatePayments"(){this.parse.show=!1,this.g.wallet.currency&&this.$q.localStorage.getItem("lnbits.exchangeRate."+this.g.wallet.currency)&&(this.g.exchangeRate=this.$q.localStorage.getItem("lnbits.exchangeRate."+this.g.wallet.currency),this.g.fiatBalance=this.g.exchangeRate/1e8*this.g.wallet.sat)},"g.wallet"(){this.g.wallet.currency?(this.g.fiatTracking=!0,this.g.fiatBalance=this.g.exchangeRate/1e8*this.g.wallet.sat):(this.g.fiatBalance=0,this.g.fiatTracking=!1)},"g.isFiatPriority"(){this.receive.unit=this.g.isFiatPriority?this.g.wallet.currency:"sat"},"g.fiatBalance"(){this.formattedFiatAmount=LNbits.utils.formatCurrency(this.g.fiatBalance.toFixed(2),this.g.wallet.currency)},"g.exchangeRate"(){this.g.fiatTracking&&this.g.wallet.currency&&(this.g.fiatBalance=this.g.exchangeRate/1e8*this.g.wallet.sat)}}},window.PageWallets={template:"#page-wallets",data:()=>({user:null,tab:"wallets",wallets:[],addWalletDialog:{show:!1},walletsTable:{columns:[{name:"name",align:"left",label:"Name",field:"name",sortable:!0},{name:"currency",align:"center",label:"Currency",field:"currency",sortable:!0},{name:"updated_at",align:"right",label:"Last Updated",field:"updated_at",sortable:!0}],pagination:{sortBy:"updated_at",rowsPerPage:12,page:1,descending:!0,rowsNumber:10},search:"",hideEmpty:!0,loading:!1}}),watch:{"walletsTable.search":{handler(){const e={};this.walletsTable.search&&(e.search=this.walletsTable.search),this.getUserWallets()}}},methods:{async getUserWallets(e){try{this.walletsTable.loading=!0;const t=LNbits.utils.prepareFilterQuery(this.walletsTable,e),{data:a}=await LNbits.api.request("GET",`/api/v1/wallet/paginated?${t}`,null);this.wallets=a.data,this.walletsTable.pagination.rowsNumber=a.total}catch(e){LNbits.utils.notifyApiError(e)}finally{this.walletsTable.loading=!1}},goToWallet(e){this.$router.push({path:"/wallet",query:{wal:e}})},formattedFiatAmount:(e,t)=>LNbits.utils.formatCurrency(Number(e).toFixed(2),t),formattedSatAmount:e=>LNbits.utils.formatMsat(e)+" sat"},async created(){await this.getUserWallets()}},window.PageUsers={template:"#page-users",data(){return{paymentsWallet:{},cancel:{},users:[],wallets:[],searchData:{user:"",username:"",email:"",pubkey:""},paymentPage:{show:!1},activeWallet:{userId:null,show:!1},activeUser:{data:null,showUserId:!1,show:!1},createWalletDialog:{data:{},show:!1},walletTable:{columns:[{name:"name",align:"left",label:"Name",field:"name"},{name:"id",align:"left",label:"Wallet Id",field:"id"},{name:"currency",align:"left",label:"Currency",field:"currency"},{name:"balance_msat",align:"left",label:"Balance",field:"balance_msat"}],pagination:{sortBy:"name",rowsPerPage:10,page:1,descending:!0,rowsNumber:10},search:null,hideEmpty:!0,loading:!1},usersTable:{columns:[{name:"activated",align:"left",label:this.$t("activated"),field:"activated",sortable:!1},{name:"wallet_id",align:"left",label:"Wallets",field:"wallet_id",sortable:!1},{name:"id",align:"left",label:"User Id",field:"id",sortable:!1},{name:"username",align:"left",label:"Username",field:"username",sortable:!1},{name:"email",align:"left",label:"Email",field:"email",sortable:!1},{name:"pubkey",align:"left",label:"Public Key",field:"pubkey",sortable:!1},{name:"balance_msat",align:"left",label:"Balance",field:"balance_msat",sortable:!1},{name:"transaction_count",align:"left",label:"Payments",field:"transaction_count",sortable:!1},{name:"last_payment",align:"left",label:"Last Payment",field:"last_payment",sortable:!1}],pagination:{sortBy:"created_at",rowsPerPage:10,page:1,descending:!0,rowsNumber:10},sortFields:[{name:"id",label:"User ID"},{name:"username",label:"Username"},{name:"email",label:"Email"},{name:"pubkey",label:"Public Key"},{name:"created_at",label:"Creation Date"},{name:"updated_at",label:"Last Updated"}],search:null,hideEmpty:!0,loading:!1}}},watch:{"usersTable.hideEmpty":function(e,t){this.usersTable.filter=e?{"transaction_count[gt]":0}:{},this.fetchUsers()}},created(){this.fetchUsers()},methods:{formatSat:e=>LNbits.utils.formatSat(Math.floor(e/1e3)),backToUsersPage(){this.activeUser.show=!1,this.paymentPage.show=!1,this.activeWallet.show=!1,this.fetchUsers()},handleBalanceUpdate(){this.fetchWallets(this.activeWallet.userId)},resetPassword(e){return LNbits.api.request("PUT",`/users/api/v1/user/${e}/reset_password`).then(e=>{LNbits.utils.confirmDialog(this.$t("reset_key_generated")+" "+this.$t("reset_key_copy")).onOk(()=>{const t=window.location.origin+"?reset_key="+e.data;this.utils.copyText(t)})}).catch(LNbits.utils.notifyApiError)},sortByColumn(e){this.usersTable.pagination.sortBy===e?this.usersTable.pagination.descending=!this.usersTable.pagination.descending:(this.usersTable.pagination.sortBy=e,this.usersTable.pagination.descending=!1),this.fetchUsers()},createUser(){LNbits.api.request("POST","/users/api/v1/user",null,this.activeUser.data).then(e=>{Quasar.Notify.create({type:"positive",message:"User created!",icon:null}),this.activeUser.setPassword=!0,this.activeUser.data=e.data,this.fetchUsers()}).catch(LNbits.utils.notifyApiError)},updateUser(){LNbits.api.request("PUT",`/users/api/v1/user/${this.activeUser.data.id}`,null,this.activeUser.data).then(()=>{Quasar.Notify.create({type:"positive",message:"User updated!",icon:null}),this.activeUser.data=null,this.activeUser.show=!1,this.fetchUsers()}).catch(LNbits.utils.notifyApiError)},createWallet(){const e=this.activeWallet.userId;e?LNbits.api.request("POST",`/users/api/v1/user/${e}/wallet`,null,this.createWalletDialog.data).then(()=>{this.fetchWallets(e),Quasar.Notify.create({type:"positive",message:"Wallet created!"})}).catch(LNbits.utils.notifyApiError):Quasar.Notify.create({type:"warning",message:"No user selected!",icon:null})},deleteUser(e){LNbits.utils.confirmDialog("Are you sure you want to delete this user?").onOk(()=>{LNbits.api.request("DELETE",`/users/api/v1/user/${e}`).then(()=>{this.fetchUsers(),Quasar.Notify.create({type:"positive",message:"User deleted!",icon:null}),this.activeUser.data=null,this.activeUser.show=!1}).catch(LNbits.utils.notifyApiError)})},undeleteUserWallet(e,t){LNbits.api.request("PUT",`/users/api/v1/user/${e}/wallet/${t}/undelete`).then(()=>{this.fetchWallets(e),Quasar.Notify.create({type:"positive",message:"Undeleted user wallet!",icon:null})}).catch(LNbits.utils.notifyApiError)},deleteUserWallet(e,t,a){const s=a?"Wallet is already deleted, are you sure you want to permanently delete this user wallet?":"Are you sure you want to delete this user wallet?";LNbits.utils.confirmDialog(s).onOk(()=>{LNbits.api.request("DELETE",`/users/api/v1/user/${e}/wallet/${t}`).then(()=>{this.fetchWallets(e),Quasar.Notify.create({type:"positive",message:"User wallet deleted!",icon:null})}).catch(LNbits.utils.notifyApiError)})},deleteAllUserWallets(e){LNbits.utils.confirmDialog(this.$t("confirm_delete_all_wallets")).onOk(()=>{LNbits.api.request("DELETE",`/users/api/v1/user/${e}/wallets`).then(t=>{Quasar.Notify.create({type:"positive",message:t.data.message,icon:null}),this.fetchWallets(e)}).catch(LNbits.utils.notifyApiError)})},copyWalletLink(e){const t=`${window.location.origin}/wallet?usr=${this.activeWallet.userId}&wal=${e}`;this.utils.copyText(t)},fetchUsers(e){this.relaxFilterForFields(["username","email"]);const t=LNbits.utils.prepareFilterQuery(this.usersTable,e);LNbits.api.request("GET",`/users/api/v1/user?${t}`).then(e=>{this.usersTable.loading=!1,this.usersTable.pagination.rowsNumber=e.data.total,this.users=e.data.data}).catch(LNbits.utils.notifyApiError)},fetchWallets(e){return LNbits.api.request("GET",`/users/api/v1/user/${e}/wallet`).then(t=>{this.wallets=t.data,this.activeWallet.userId=e,this.activeWallet.show=!0}).catch(LNbits.utils.notifyApiError)},relaxFilterForFields(e=[]){e.forEach(e=>{const t=this.usersTable?.filter?.[e];t&&this.usersTable.filter[e]&&(this.usersTable.filter[`${e}[like]`]=t,delete this.usersTable.filter[e])})},updateWallet(e){LNbits.api.request("PATCH","/api/v1/wallet",e.adminkey,{name:e.name}).then(()=>{e.editable=!1,Quasar.Notify.create({message:"Wallet name updated.",type:"positive",timeout:3500})}).catch(e=>{LNbits.utils.notifyApiError(e)})},toggleAdmin(e){LNbits.api.request("PUT",`/users/api/v1/user/${e}/admin`).then(()=>{this.fetchUsers(),Quasar.Notify.create({type:"positive",message:"Toggled admin!",icon:null})}).catch(LNbits.utils.notifyApiError)},toggleUserActivated(e){LNbits.api.request("PUT",`/users/api/v1/user/${e}/activate`).then(e=>{this.fetchUsers(),Quasar.Notify.create({type:"positive",message:e.data.message,icon:null})}).catch(LNbits.utils.notifyApiError)},async showAccountPage(e){if(this.activeUser.showPassword=!1,this.activeUser.showUserId=!1,this.activeUser.setPassword=!1,!e)return this.activeUser.data={extra:{}},void(this.activeUser.show=!0);try{const{data:t}=await LNbits.api.request("GET",`/users/api/v1/user/${e}`);this.activeUser.data=t,this.activeUser.show=!0}catch(e){console.warn(e),Quasar.Notify.create({type:"warning",message:"Failed to get user!"}),this.activeUser.show=!1}},async impersonateUser(e){try{await LNbits.api.impersonateUser(e),LNbits.utils.backupLocalStorage("impersonation",!0),this.$q.localStorage.setItem("lnbits.disclaimerShown",!0),window.location="/wallet"}catch(e){console.warn(e),Quasar.Notify.create({type:"warning",message:"Failed to impersonate user!"})}},async showWalletPayments(e){this.activeUser.show=!1,await this.fetchWallets(this.users[0].id),await this.showPayments(e)},showPayments(e){this.paymentsWallet=this.wallets.find(t=>t.id===e),this.paymentPage.show=!0},searchUserBy(e){const t=this.searchData[e];this.usersTable.filter={},t&&(this.usersTable.filter[e]=t),this.fetchUsers()},shortify:e=>(valueLength=(e||"").length,valueLength<=10?e:`${e.substring(0,5)}...${e.substring(valueLength-5,valueLength)}`)}},window.PageAccount={template:"#page-account",data(){return{untouchedUser:null,hasUsername:!1,showUserId:!1,themeOptions:[{name:"bitcoin",color:"deep-orange"},{name:"classic",color:"purple"},{name:"mint",color:"green"},{name:"autumn",color:"brown"},{name:"monochrome",color:"grey"},{name:"salvador",color:"blue-10"},{name:"freedom",color:"pink-13"},{name:"cyber",color:"light-green-9"},{name:"flamingo",color:"pink-3"}],defaultSiteCustomisation:{locale:"en"},reactionOptions:["None","confettiBothSides","confettiFireworks","confettiStars","confettiTop"],borderOptions:["retro-border","hard-border","neon-border","no-border"],tab:"user",credentialsData:{show:!1,oldPassword:null,newPassword:null,newPasswordRepeat:null,username:null,pubkey:null},apiAcl:{showNewAclDialog:!1,showPasswordDialog:!1,showNewTokenDialog:!1,data:[],passwordGuardedFunction:null,newAclName:"",newTokenName:"",password:"",apiToken:null,selectedTokenId:null,columns:[{name:"Name",align:"left",label:this.$t("Name"),field:"Name",sortable:!1},{name:"path",align:"left",label:this.$t("path"),field:"path",sortable:!1},{name:"read",align:"left",label:this.$t("read"),field:"read",sortable:!1},{name:"write",align:"left",label:this.$t("write"),field:"write",sortable:!1}],pagination:{rowsPerPage:100,page:1}},selectedApiAcl:{id:null,name:null,endpoints:[],token_id_list:[],allRead:!1,allWrite:!1},assets:[],assetsTable:{loading:!1,columns:[{name:"name",align:"left",label:this.$t("Name"),field:"name",sortable:!0},{name:"created_at",align:"left",label:this.$t("created_at"),field:"created_at",sortable:!0}],pagination:{rowsPerPage:6,page:1}},assetsUploadToPublic:!1,notifications:{nostr:{identifier:""}},labels:[],labelsDialog:{show:!1,data:{name:"",description:"",color:"#000000"}},labelsTable:{loading:!1,columns:[{name:"actions",align:"left"},{name:"name",align:"left",label:this.$t("Name"),field:"name",sortable:!0},{name:"description",align:"left",label:this.$t("description"),field:"description"},{name:"color",align:"left",label:this.$t("color"),field:"color"}],pagination:{rowsPerPage:6,page:1}}}},watch:{tab(e){this.$router.push(`/account#${e}`)},$route(e){e.hash.length>1&&(this.tab=e.hash.replace("#",""))},"assetsTable.search":{handler(){const e={};this.assetsTable.search&&(e.search=this.assetsTable.search),this.getUserAssets()}}},computed:{isUserTouched(){return!_.isEqual(this.g.user,this.untouchedUser)}},methods:{changeLanguage(e){window.i18n.global.locale=e,this.$q.localStorage.set("lnbits.lang",e)},async updateAccount(){try{const{data:e}=await LNbits.api.request("PATCH","/api/v1/auth",null,{user_id:this.g.user.id,username:this.g.user.username,email:this.g.user.email,extra:this.g.user.extra});this.untouchedUser=JSON.parse(JSON.stringify(this.g.user)),this.hasUsername=!!e.username,Quasar.Notify.create({type:"positive",message:"Account updated."})}catch(e){LNbits.utils.notifyApiError(e)}},disableUpdatePassword(){return!this.credentialsData.newPassword||!this.credentialsData.newPasswordRepeat||this.credentialsData.newPassword!==this.credentialsData.newPasswordRepeat},async updatePassword(){if(this.credentialsData.username)try{const{data:e}=await LNbits.api.request("PUT","/api/v1/auth/password",null,{user_id:this.g.user.id,username:this.credentialsData.username,password_old:this.credentialsData.oldPassword,password:this.credentialsData.newPassword,password_repeat:this.credentialsData.newPasswordRepeat});this.untouchedUser=JSON.parse(JSON.stringify(e)),this.hasUsername=!!e.username,this.credentialsData.show=!1,Quasar.Notify.create({type:"positive",message:"Password updated."})}catch(e){LNbits.utils.notifyApiError(e)}else Quasar.Notify.create({type:"warning",message:"Please set a username."})},async updatePubkey(){try{const{data:e}=await LNbits.api.request("PUT","/api/v1/auth/pubkey",null,{user_id:this.g.user.id,pubkey:this.credentialsData.pubkey});this.untouchedUser=JSON.parse(JSON.stringify(e)),this.hasUsername=!!e.username,this.credentialsData.show=!1,this.$q.notify({type:"positive",message:"Public key updated."})}catch(e){LNbits.utils.notifyApiError(e)}},showUpdateCredentials(){this.credentialsData={show:!0,oldPassword:null,username:this.g.user.username,pubkey:this.g.user.pubkey,newPassword:null,newPasswordRepeat:null}},newApiAclDialog(){this.apiAcl.newAclName=null,this.apiAcl.showNewAclDialog=!0},newTokenAclDialog(){this.apiAcl.newTokenName=null,this.apiAcl.newTokenExpiry=null,this.apiAcl.showNewTokenDialog=!0},handleApiACLSelected(e){this.selectedApiAcl={id:null,name:null,endpoints:[],token_id_list:[]},this.apiAcl.selectedTokenId=null,e&&setTimeout(()=>{const t=this.apiAcl.data.find(t=>t.id===e);this.selectedApiAcl&&(this.selectedApiAcl={...t},this.selectedApiAcl.allRead=this.selectedApiAcl.endpoints.every(e=>e.read),this.selectedApiAcl.allWrite=this.selectedApiAcl.endpoints.every(e=>e.write))})},handleAllEndpointsReadAccess(){this.selectedApiAcl.endpoints.forEach(e=>e.read=this.selectedApiAcl.allRead)},handleAllEndpointsWriteAccess(){this.selectedApiAcl.endpoints.forEach(e=>e.write=this.selectedApiAcl.allWrite)},async getApiACLs(){try{const{data:e}=await LNbits.api.request("GET","/api/v1/auth/acl",null);this.apiAcl.data=e.access_control_list}catch(e){LNbits.utils.notifyApiError(e)}},askPasswordAndRunFunction(e){this.apiAcl.passwordGuardedFunction=e,this.apiAcl.showPasswordDialog=!0},runPasswordGuardedFunction(){this.apiAcl.showPasswordDialog=!1;const e=this.apiAcl.passwordGuardedFunction;e&&this[e]()},async addApiACL(){if(this.apiAcl.newAclName){try{const{data:e}=await LNbits.api.request("PUT","/api/v1/auth/acl",null,{id:this.apiAcl.newAclName,name:this.apiAcl.newAclName,password:this.apiAcl.password});this.apiAcl.data=e.access_control_list;const t=this.apiAcl.data.find(e=>e.name===this.apiAcl.newAclName);this.handleApiACLSelected(t.id),this.apiAcl.showNewAclDialog=!1,this.$q.notify({type:"positive",message:"Access Control List created."})}catch(e){LNbits.utils.notifyApiError(e)}finally{this.apiAcl.name="",this.apiAcl.password=""}this.apiAcl.showNewAclDialog=!1}else this.$q.notify({type:"warning",message:"Name is required."})},async updateApiACLs(){try{const{data:e}=await LNbits.api.request("PUT","/api/v1/auth/acl",null,{id:this.g.user.id,password:this.apiAcl.password,...this.selectedApiAcl});this.apiAcl.data=e.access_control_list}catch(e){LNbits.utils.notifyApiError(e)}finally{this.apiAcl.password=""}},async deleteApiACL(){if(this.selectedApiAcl.id){try{await LNbits.api.request("DELETE","/api/v1/auth/acl",null,{id:this.selectedApiAcl.id,password:this.apiAcl.password}),this.$q.notify({type:"positive",message:"Access Control List deleted."})}catch(e){LNbits.utils.notifyApiError(e)}finally{this.apiAcl.password=""}this.apiAcl.data=this.apiAcl.data.filter(e=>e.id!==this.selectedApiAcl.id),this.handleApiACLSelected(this.apiAcl.data[0]?.id)}},async generateApiToken(){if(!this.selectedApiAcl.id)return;const e=new Date(this.apiAcl.newTokenExpiry)-new Date;try{const{data:t}=await LNbits.api.request("POST","/api/v1/auth/acl/token",null,{acl_id:this.selectedApiAcl.id,token_name:this.apiAcl.newTokenName,password:this.apiAcl.password,expiration_time_minutes:Math.trunc(e/6e4)});this.apiAcl.apiToken=t.api_token,this.apiAcl.selectedTokenId=t.id,Quasar.Notify.create({type:"positive",message:"Token Generated."}),await this.getApiACLs(),this.handleApiACLSelected(this.selectedApiAcl.id),this.apiAcl.showNewTokenDialog=!1}catch(e){LNbits.utils.notifyApiError(e)}finally{this.apiAcl.password=""}},async deleteToken(){if(this.apiAcl.selectedTokenId)try{await LNbits.api.request("DELETE","/api/v1/auth/acl/token",null,{id:this.apiAcl.selectedTokenId,acl_id:this.selectedApiAcl.id,password:this.apiAcl.password}),this.$q.notify({type:"positive",message:"Token deleted."}),this.selectedApiAcl.token_id_list=this.selectedApiAcl.token_id_list.filter(e=>e.id!==this.apiAcl.selectedTokenId),this.apiAcl.selectedTokenId=null}catch(e){LNbits.utils.notifyApiError(e)}finally{this.apiAcl.password=""}},async getUserAssets(e){try{this.assetsTable.loading=!0;const t=LNbits.utils.prepareFilterQuery(this.assetsTable,e),{data:a}=await LNbits.api.request("GET",`/api/v1/assets/paginated?${t}`,null);this.assets=a.data,this.assetsTable.pagination.rowsNumber=a.total}catch(e){LNbits.utils.notifyApiError(e)}finally{this.assetsTable.loading=!1}},onImageInput(e){const t=e.target.files[0];t&&this.uploadAsset(t),e.target.value=null},onBackgroundImageInput(e){const t=e.target.files[0];t&&this.uploadBackgroundImage(t),e.target.value=null},async uploadAsset(e,{isPublic:t=this.assetsUploadToPublic,notifySuccess:a=!0}={}){const s=new FormData;s.append("file",e);try{const{data:e}=await LNbits.api.request("POST",`/api/v1/assets?public_asset=${t}`,null,s,{headers:{"Content-Type":"multipart/form-data"}});return a&&this.$q.notify({type:"positive",message:"Upload successful!",icon:null}),await this.getUserAssets(),e}catch(e){console.warn(e),LNbits.utils.notifyApiError(e)}},async uploadBackgroundImage(e){const t=await this.uploadAsset(e,{isPublic:!1,notifySuccess:!1});if(!t)return;const a=`${window.location.origin}/api/v1/assets/${t.id}/thumbnail`;await this.siteCustomisationChanged({bgimageChoice:a})},async deleteAsset(e){LNbits.utils.confirmDialog("Are you sure you want to delete this asset?").onOk(async()=>{try{await LNbits.api.request("DELETE",`/api/v1/assets/${e.id}`,null),this.$q.notify({type:"positive",message:"Asset deleted."}),await this.getUserAssets()}catch(e){console.warn(e),LNbits.utils.notifyApiError(e)}})},async toggleAssetPublicAccess(e){try{await LNbits.api.request("PUT",`/api/v1/assets/${e.id}`,null,{is_public:!e.is_public}),this.$q.notify({type:"positive",message:"Update successful!",icon:null}),await this.getUserAssets()}catch(e){console.warn(e),LNbits.utils.notifyApiError(e)}},copyAssetLinkToClipboard(e){const t=`${window.location.origin}/api/v1/assets/${e.id}/data`;this.utils.copyText(t)},addUserLabel(){if(!this.labelsDialog.data.name)return void this.$q.notify({type:"warning",message:"Name is required."});if(!this.labelsDialog.data.color)return void this.$q.notify({type:"warning",message:"Color is required."});this.g.user.extra.labels=this.g.user.extra.labels||[];if(!this.g.user.extra.labels.find(e=>e.name===this.labelsDialog.data.name))return this.g.user.extra.labels.unshift({...this.labelsDialog.data}),this.labelsDialog.show=!1,!0;this.$q.notify({type:"warning",message:"A label with this name already exists."})},openAddLabelDialog(){this.labelsDialog.data={name:"",description:"",color:"#000000"},this.labelsDialog.show=!0},openEditLabelDialog(e){this.labelsDialog.data={name:e.name,description:e.description,color:e.color},this.labelsDialog.show=!0},updateUserLabel(){const e=this.labelsDialog.data,t=JSON.parse(JSON.stringify(this.g.user.extra.labels));this.g.user.extra.labels=this.g.user.extra.labels.filter(t=>t.name!==e.name);this.addUserLabel()||(this.g.user.extra.labels=t),this.labelsDialog.show=!1},deleteUserLabel(e){LNbits.utils.confirmDialog("Are you sure you want to delete this label?").onOk(()=>{this.g.user.extra.labels=this.g.user.extra.labels.filter(t=>t.name!==e.name)})},async siteCustomisationChanged(e={}){try{Object.entries(e||{}).forEach(([e,t])=>{e in this.g&&(this.g[e]=t)}),await LNbits.api.updateUiCustomization(e),this.$q.notify({type:"positive",message:"UI Customization updated."})}catch(e){LNbits.utils.notifyApiError(e)}},resetThemeDefaults(){const e={themeChoice:this.g.settings.defaultTheme,borderChoice:this.g.settings.defaultBorder,gradientChoice:this.g.settings.defaultGradient,bgimageChoice:this.g.settings.defaultBgimage||"",reactionChoice:this.g.settings.defaultReaction,darkChoice:this.g.settings.defaultDark,cardRoundedChoice:this.g.settings.defaultCardRounded,cardGradientChoice:this.g.settings.defaultCardGradient,cardShadowChoice:this.g.settings.defaultCardShadow,burgerMenuChoice:this.g.settings.defaultBurgerMenuBackground};this.siteCustomisationChanged(e)}},async created(){this.untouchedUser=JSON.parse(JSON.stringify(this.g.user)),this.hasUsername=!!this.g.user.username,this.$route.hash.length>1&&(this.tab=this.$route.hash.replace("#","")),await this.getApiACLs(),await this.getUserAssets(),this.themeOptions=this.themeOptions.filter(e=>this.g.settings.themeOptions.includes(e.name))}},window.PageAdmin={template:"#page-admin",data:()=>({tab:"funding",settings:{},formData:{lnbits_exchange_rate_providers:[],lnbits_audit_exclude_paths:[],lnbits_audit_include_paths:[],lnbits_audit_http_response_codes:[]},isSuperUser:!1,needsRestart:!1}),watch:{tab(e){this.$router.push(`/admin#${e}`)},$route(e){e.hash.length>1&&(this.tab=e.hash.replace("#",""))}},async created(){this.$route.hash.length>1&&(this.tab=this.$route.hash.replace("#","")),await this.getSettings()},computed:{checkChanges(){return!_.isEqual(this.settings,this.formData)}},methods:{getDefaultSetting(e){LNbits.api.getDefaultSetting(e).then(t=>{this.formData[e]=t.data.default_value})},restartServer(){LNbits.api.request("GET","/admin/api/v1/restart/").then(e=>{this.$q.notify({type:"positive",message:"Success! Restarted Server",icon:null}),this.needsRestart=!1}).catch(LNbits.utils.notifyApiError)},async getSettings(){await LNbits.api.request("GET","/admin/api/v1/settings",this.g.user.wallets[0].adminkey).then(e=>{this.isSuperUser=e.data.is_super_user||!1,this.settings=e.data,this.formData={...this.settings}}).catch(LNbits.utils.notifyApiError)},updateSettings(){const e=_.omit(this.formData,["is_super_user","lnbits_allowed_funding_sources","touch"]);LNbits.api.request("PUT","/admin/api/v1/settings",this.g.user.wallets[0].adminkey,e).then(e=>{this.needsRestart=this.settings.lnbits_backend_wallet_class!==this.formData.lnbits_backend_wallet_class,this.settings=this.formData,this.formData=_.clone(this.settings),Quasar.Notify.create({type:"positive",message:"Success! Settings changed! "+(this.needsRestart?"Restart required!":""),icon:null})}).catch(LNbits.utils.notifyApiError)},deleteSettings(){LNbits.utils.confirmDialog("Are you sure you want to restore settings to default?").onOk(()=>{LNbits.api.request("DELETE","/admin/api/v1/settings").then(e=>{Quasar.Notify.create({type:"positive",message:"Success! Restored settings to defaults. Restarting...",icon:null}),this.$q.localStorage.clear()}).catch(LNbits.utils.notifyApiError)})},downloadBackup(){window.open("/admin/api/v1/backup","_blank")}}},window.app.component("lnbits-admin-funding-seed-backup",{props:["active","is-super-user","form-data","settings"],template:"#lnbits-admin-funding-seed-backup",data:()=>({dialog:{show:!1,step:1,seed:"",visible:!1,challenge:[],answers:{},error:"",confirmField:""}}),watch:{active(e){e&&this.openIfRequired()},"formData.lnbits_backend_wallet_class"(e,t){const a=this.seedBackupSource(e);t&&a&&this.formData[a.seedField]&&(this.formData[a.confirmField]=!1),this.openIfRequired()},"formData.boltz_mnemonic"(){this.formData.boltz_mnemonic_backup_confirmed=this.formData.boltz_mnemonic===this.settings.boltz_mnemonic&&this.settings.boltz_mnemonic_backup_confirmed,this.openIfRequired()},"formData.phoenixd_mnemonic"(){this.formData.phoenixd_mnemonic_backup_confirmed=this.formData.phoenixd_mnemonic===this.settings.phoenixd_mnemonic&&this.settings.phoenixd_mnemonic_backup_confirmed,this.openIfRequired()},"formData.spark_l2_mnemonic"(){this.formData.spark_l2_mnemonic_backup_confirmed=this.formData.spark_l2_mnemonic===this.settings.spark_l2_mnemonic&&this.settings.spark_l2_mnemonic_backup_confirmed,this.openIfRequired()}},computed:{seedWords(){return this.dialog.seed.split(/\s+/).filter(Boolean).map((e,t)=>({index:t,word:e}))}},created(){this.openIfRequired()},methods:{seedBackupSource(e=this.formData.lnbits_backend_wallet_class){return"BoltzWallet"===e?{seedField:"boltz_mnemonic",confirmField:"boltz_mnemonic_backup_confirmed"}:"PhoenixdWallet"===e?{seedField:"phoenixd_mnemonic",confirmField:"phoenixd_mnemonic_backup_confirmed"}:"SparkL2Wallet"===e?{seedField:"spark_l2_mnemonic",confirmField:"spark_l2_mnemonic_backup_confirmed"}:void 0},openIfRequired(){if(!this.active||!this.isSuperUser)return;const e=this.seedBackupSource();if(!e)return;const t=(this.formData[e.seedField]||"").trim(),a=this.formData[e.confirmField];!t||a||this.dialog.show||(this.dialog={show:!0,step:1,seed:t,visible:!1,challenge:[],answers:{},error:"",confirmField:e.confirmField})},prepareChallenge(){const e=this.dialog.seed.split(/\s+/).filter(Boolean),t=Math.min(4,e.length),a=_.shuffle([...Array(e.length).keys()]).slice(0,t);this.dialog.challenge=a.sort((e,t)=>e-t).map(t=>({index:t,word:e[t]})),this.dialog.answers={},this.dialog.error="",this.dialog.step=2},submitChallenge(){if(!this.dialog.challenge.every(({index:e,word:t})=>(this.dialog.answers[e]||"").trim().toLowerCase()===t.toLowerCase()))return void(this.dialog.error="One or more words are incorrect. Check your backup and try again.");const e=this.dialog.confirmField;LNbits.api.request("PATCH","/admin/api/v1/settings",this.g.user.wallets[0].adminkey,{[e]:!0}).then(()=>{this.formData[e]=!0,this.settings[e]=!0,this.dialog.show=!1,Quasar.Notify.create({type:"positive",message:"Seed backup confirmed",icon:"check"})}).catch(LNbits.utils.notifyApiError)}}}),window.app.component("lnbits-admin-funding",{props:["active","is-super-user","form-data","settings"],template:"#lnbits-admin-funding",data:()=>({auditData:[]}),created(){this.getAudit()},methods:{getAudit(){LNbits.api.request("GET","/admin/api/v1/audit",this.g.user.wallets[0].adminkey).then(e=>{this.auditData=e.data}).catch(LNbits.utils.notifyApiError)}}}),window.app.component("lnbits-admin-funding-sources",{template:"#lnbits-admin-funding-sources",props:["form-data","allowed-funding-sources"],methods:{getFundingSourceLabel(e){const t=this.rawFundingSources.find(t=>t[0]===e);return t?t[1]:e},showQRValue(e){this.qrValue=e,this.showQRDialog=!0}},computed:{fundingSources(){let e=[];for(const[t,a,s]of this.rawFundingSources){const a={};if(null!==s)for(let[e,t]of Object.entries(s))a[e]="string"==typeof t?{label:t,value:null}:t||{};e.push([t,a])}return new Map(e)},sortedAllowedFundingSources(){return this.allowedFundingSources.sort()}},data:()=>({hideInput:!0,showQRDialog:!1,qrValue:"",rawFundingSources:[["VoidWallet","Void Wallet",null],["FakeWallet","Fake Wallet",{fake_wallet_secret:"Secret",lnbits_denomination:'"sats" or 3 Letter Custom Denomination'}],["CLNRestWallet","Core Lightning Rest (plugin)",{clnrest_url:"Endpoint",clnrest_ca:"ca.pem",clnrest_cert:"server.pem",clnrest_readonly_rune:"Rune used for readonly requests",clnrest_invoice_rune:"Rune used for creating invoices",clnrest_pay_rune:"Rune used for paying invoices using pay",clnrest_renepay_rune:"Rune used for paying invoices using renepay",clnrest_last_pay_index:"Ignores any invoices paid prior to or including this index. 0 is equivalent to not specifying and negative value is invalid.",clnrest_nodeid:"Node id"}],["CoreLightningWallet","Core Lightning",{corelightning_rpc:"Endpoint",corelightning_pay_command:"Custom Pay Command"}],["CoreLightningRestWallet","Core Lightning Rest (legacy)",{corelightning_rest_url:"Endpoint",corelightning_rest_cert:"Certificate",corelightning_rest_macaroon:"Macaroon"}],["LndRestWallet","Lightning Network Daemon (LND Rest)",{lnd_rest_endpoint:"Endpoint",lnd_rest_cert:"Certificate",lnd_rest_macaroon:"Macaroon",lnd_rest_macaroon_encrypted:"Encrypted Macaroon",lnd_rest_route_hints:{advanced:!0,label:"Enable Route Hints"},lnd_rest_allow_self_payment:{advanced:!0,label:"Allow Self Payment"}}],["LndWallet","Lightning Network Daemon (LND)",{lnd_grpc_endpoint:"Endpoint",lnd_grpc_cert:"Certificate",lnd_grpc_port:"Port",lnd_grpc_macaroon:"GRPC Macaroon",lnd_grpc_invoice_macaroon:"GRPC Invoice Macaroon",lnd_grpc_admin_macaroon:"GRPC Admin Macaroon",lnd_grpc_macaroon_encrypted:"Encrypted Macaroon"}],["LnTipsWallet","LN.Tips",{lntips_api_endpoint:"Endpoint",lntips_api_key:"API Key"}],["LNPayWallet","LN Pay",{lnpay_api_endpoint:"Endpoint",lnpay_api_key:"API Key",lnpay_wallet_key:"Wallet Key"}],["EclairWallet","Eclair (ACINQ)",{eclair_url:"URL",eclair_pass:"Password"}],["LNbitsWallet","LNbits",{lnbits_endpoint:"Endpoint",lnbits_key:"Admin Key"}],["BlinkWallet","Blink",{blink_api_endpoint:"Endpoint",blink_ws_endpoint:"WebSocket",blink_token:"Key"}],["AlbyWallet","Alby",{alby_api_endpoint:"Endpoint",alby_access_token:"Key"}],["BoltzWallet","Boltz",{boltz_client_endpoint:{label:"Boltz client endpoint",value:"127.0.0.1:9002"},boltz_client_macaroon:{label:"Admin Macaroon path or hex",value:"/home/ubuntu/.boltz/macaroons/admin.macaroon"},boltz_client_cert:{label:"Certificate path or hex",value:"/home/ubuntu/.boltz/tls.cert"},boltz_mnemonic:{label:"Liquid seed phrase",hint:"Boltz will fetch once connected, but you can change later (can be opened in a liquid wallet) ",copy:!0,qrcode:!0},boltz_client_password:{label:"Wallet Password (optional)",advanced:!0}}],["ZBDWallet","ZBD",{zbd_api_endpoint:"Endpoint",zbd_api_key:"Key"}],["PhoenixdWallet","Phoenixd",{phoenixd_api_endpoint:"Endpoint",phoenixd_api_password:"Key",phoenixd_data_dir:{label:"Data Directory",hint:"Directory where phoenixd stores its data, including the seed phrase."},phoenixd_mnemonic:{label:"Phoenixd Seed Phrase",hint:"Only available if phoenixd data-dir is specified",readonly:!0,copy:!0,qrcode:!0}}],["OpenNodeWallet","OpenNode",{opennode_api_endpoint:"Endpoint",opennode_key:"Key"}],["ClicheWallet","Cliche (NBD)",{cliche_endpoint:"Endpoint"}],["SparkWallet","Spark",{spark_url:"Endpoint",spark_token:"Token"}],["SparkL2Wallet","Spark (L2)",{spark_l2_external_endpoint:{label:"External Sidecar Endpoint",hint:"Make sure to also specify the API key if your sidecar requires authentication.",value:""},spark_l2_mnemonic:{label:"External Sidecar Mnemonic",hint:"Mnemonic for the Spark wallet on the external sidecar. Required if the side car does not have its own mnemonic.",value:""},spark_l2_external_api_key:{label:"External Sidecar API Key",hint:"API key for authenticating with the external sidecar if it requires authentication.",value:""},spark_l2_network:{label:"Network",value:"MAINNET",hint:"The network to use for the Spark wallet.",advanced:!0},spark_l2_pay_wait_ms:{label:"Payment Wait Time (ms)",hint:"The time to wait for a payment to be processed before considering it failed.",advanced:!0},spark_l2_pay_poll_ms:{label:"Payment Poll Time (ms)",hint:"The time to wait between polling for payment status updates.",advanced:!0},spark_l2_stream_keepalive_ms:{label:"Stream Keepalive Time (ms)",hint:"The time to wait between sending keepalive messages to the Spark sidecar to keep the connection open.",advanced:!0}}],["NWCWallet","Nostr Wallet Connect",{nwc_pairing_url:"Pairing URL"}],["BreezSdkWallet","Breez SDK",{breez_api_key:"Breez API Key",breez_greenlight_seed:"Greenlight Seed",breez_greenlight_device_key:"Greenlight Device Key",breez_greenlight_device_cert:"Greenlight Device Cert",breez_greenlight_invite_code:"Greenlight Invite Code"}],["StrikeWallet","Strike (alpha)",{strike_api_endpoint:"API Endpoint",strike_api_key:"API Key"}],["BreezLiquidSdkWallet","Breez Liquid SDK",{breez_liquid_api_key:"Breez API Key (can be empty)",breez_liquid_seed:"Liquid seed phrase",breez_liquid_fee_offset_sat:"Offset amount in sats to increase fee limit"}]]})}),window.app.component("lnbits-admin-fiat-providers",{props:["form-data"],template:"#lnbits-admin-fiat-providers",data:()=>({formAddStripeUser:"",formAddPaypalUser:"",formAddSquareUser:"",formAddRevolutUser:"",creatingRevolutWebhook:!1,hideInputToggle:!0}),computed:{stripeWebhookUrl(){return this.formData?.stripe_payment_webhook_url||this.calculateWebhookUrl("stripe")},paypalWebhookUrl(){return this.formData?.paypal_payment_webhook_url||this.calculateWebhookUrl("paypal")},revolutWebhookUrl(){return this.formData?.revolut_payment_webhook_url||this.calculateWebhookUrl("revolut")}},watch:{formData:{handler(){this.syncWebhookUrls()},immediate:!0}},methods:{basePathFromLocation(){if("undefined"==typeof window)return"";const e=window.location.pathname.replace(/\/+$/,""),t=e.lastIndexOf("/admin");return(t>=0?e.slice(0,t):e||"")||""},calculateWebhookUrl(e){if("undefined"==typeof window)return"";const t=`${this.basePathFromLocation()}/api/v1/callback/${e}`.replace(/\/+/g,"/"),a=t.startsWith("/")?t:`/${t}`;return`${window.location.origin}${a}`},syncWebhookUrls(){this.maybeSetWebhookUrl("stripe_payment_webhook_url","stripe"),this.maybeSetWebhookUrl("paypal_payment_webhook_url","paypal"),this.maybeSetWebhookUrl("square_payment_webhook_url","square"),this.maybeSetWebhookUrl("revolut_payment_webhook_url","revolut")},maybeSetWebhookUrl(e,t){if(!this.formData)return;const a=this.calculateWebhookUrl(t),s=this.formData[e];(!s||s.includes("your-lnbits-domain-here.com"))&&a&&(this.formData[e]=a)},copyWebhookUrl(e){e&&this.copyText(e)},isClearnetWebhookUrl(e){let t;try{t=new URL(e)}catch(e){return!1}const a=t.hostname.toLowerCase();return!!["http:","https:"].includes(t.protocol)&&(!("localhost"===a||a.endsWith(".localhost")||a.endsWith(".local")||a.endsWith(".onion"))&&!(/^127\./.test(a)||/^10\./.test(a)||/^192\.168\./.test(a)||/^169\.254\./.test(a)||/^172\.(1[6-9]|2\d|3[0-1])\./.test(a)||"0.0.0.0"===a||"::1"===a))},notifyRevolutWebhookWarning(e){Quasar.Notify.create({type:"warning",message:e,icon:null,closeBtn:!0})},addStripeAllowedUser(){const e=this.formAddStripeUser||"";e.length&&!this.formData.stripe_limits.allowed_users.includes(e)&&(this.formData.stripe_limits.allowed_users=[...this.formData.stripe_limits.allowed_users,e],this.formAddStripeUser="")},removeStripeAllowedUser(e){this.formData.stripe_limits.allowed_users=this.formData.stripe_limits.allowed_users.filter(t=>t!==e)},addPaypalAllowedUser(){const e=this.formAddPaypalUser||"";e.length&&!this.formData.paypal_limits.allowed_users.includes(e)&&(this.formData.paypal_limits.allowed_users=[...this.formData.paypal_limits.allowed_users,e],this.formAddPaypalUser="")},removePaypalAllowedUser(e){this.formData.paypal_limits.allowed_users=this.formData.paypal_limits.allowed_users.filter(t=>t!==e)},addSquareAllowedUser(){const e=this.formAddSquareUser||"";e.length&&!this.formData.square_limits.allowed_users.includes(e)&&(this.formData.square_limits.allowed_users=[...this.formData.square_limits.allowed_users,e],this.formAddSquareUser="")},removeSquareAllowedUser(e){this.formData.square_limits.allowed_users=this.formData.square_limits.allowed_users.filter(t=>t!==e)},addRevolutAllowedUser(){const e=this.formAddRevolutUser||"";e.length&&!this.formData.revolut_limits.allowed_users.includes(e)&&(this.formData.revolut_limits.allowed_users=[...this.formData.revolut_limits.allowed_users,e],this.formAddRevolutUser="")},removeRevolutAllowedUser(e){this.formData.revolut_limits.allowed_users=this.formData.revolut_limits.allowed_users.filter(t=>t!==e)},checkFiatProvider(e){LNbits.api.request("PUT",`/api/v1/fiat/check/${e}`).then(e=>{const t=e.data;Quasar.Notify.create({type:t.success?"positive":"warning",message:t.message,icon:null})}).catch(LNbits.utils.notifyApiError)},createRevolutWebhook(){const e=this.calculateWebhookUrl("revolut");this.formData.revolut_payment_webhook_url=e,this.formData.revolut_api_secret_key?this.isClearnetWebhookUrl(e)?(this.creatingRevolutWebhook=!0,LNbits.api.request("POST","/api/v1/fiat/revolut/webhook",null,{url:e,endpoint:this.formData.revolut_api_endpoint,api_secret_key:this.formData.revolut_api_secret_key,api_version:this.formData.revolut_api_version}).then(e=>{const t=e.data;this.formData.revolut_payment_webhook_url=t.url,this.formData.revolut_webhook_signing_secret=t.signing_secret,Quasar.Notify.create({type:"positive",message:`Revolut webhook ${t.already_exists?"already exists":"created"}${t.id?`: ${t.id}`:""}.`,icon:null})}).catch(LNbits.utils.notifyApiError).finally(()=>{this.creatingRevolutWebhook=!1})):this.notifyRevolutWebhookWarning("Revolut webhook URL must be a clearnet URL."):this.notifyRevolutWebhookWarning("Add your Revolut API secret key before creating a webhook.")}}}),window.app.component("lnbits-admin-exchange-providers",{props:["form-data"],template:"#lnbits-admin-exchange-providers",data:()=>({exchangeData:{selectedProvider:null,showTickerConversion:!1,convertFromTicker:null,convertToTicker:null},exchangesTable:{columns:[{name:"name",align:"left",label:"Exchange Name",field:"name",sortable:!0},{name:"api_url",align:"left",label:"URL",field:"api_url",sortable:!1},{name:"path",align:"left",label:"JSON Path",field:"path",sortable:!1},{name:"exclude_to",align:"left",label:"Exclude Currencies",field:"exclude_to",sortable:!1},{name:"ticker_conversion",align:"left",label:"Ticker Conversion",field:"ticker_conversion",sortable:!1}],pagination:{sortBy:"name",rowsPerPage:100,page:1,rowsNumber:100},search:null,hideEmpty:!0}}),mounted(){this.getExchangeRateHistory()},methods:{getDefaultSetting(e){LNbits.api.getDefaultSetting(e).then(t=>{this.formData[e]=t.data.default_value})},getExchangeRateHistory(){LNbits.api.request("GET","/api/v1/rate/history",this.g.user.wallets[0].inkey).then(e=>{this.initExchangeChart(e.data)}).catch(function(e){LNbits.utils.notifyApiError(e)})},showExchangeProvidersTab(e){"exchange_providers"===e&&this.getExchangeRateHistory()},addExchangeProvider(){this.formData.lnbits_exchange_rate_providers=[{name:"",api_url:"",path:"",exclude_to:[]},...this.formData.lnbits_exchange_rate_providers]},removeExchangeProvider(e){this.formData.lnbits_exchange_rate_providers=this.formData.lnbits_exchange_rate_providers.filter(t=>t!==e)},removeExchangeTickerConversion(e,t){e.ticker_conversion=e.ticker_conversion.filter(e=>e!==t),this.formData.touch=null},addExchangeTickerConversion(){this.exchangeData.selectedProvider&&(this.exchangeData.selectedProvider.ticker_conversion.push(`${this.exchangeData.convertFromTicker}:${this.exchangeData.convertToTicker}`),this.formData.touch=null,this.exchangeData.showTickerConversion=!1)},showTickerConversionDialog(e){this.exchangeData.convertFromTicker=null,this.exchangeData.convertToTicker=null,this.exchangeData.selectedProvider=e,this.exchangeData.showTickerConversion=!0},initExchangeChart(e){this.exchangeRatesChart&&(this.exchangeRatesChart.destroy(),this.exchangeRatesChart=null);const t=e.map(e=>this.utils.formatTimestamp(e.timestamp,"HH:mm")),a=(this.formData.lnbits_price_aggregator_enabled?[{name:"Aggregator"}]:[...this.formData.lnbits_exchange_rate_providers,{name:"LNbits"}]).map(t=>({label:t.name,data:e.map(e=>e.rates[t.name]),pointStyle:!0,borderWidth:"LNbits"===t.name?4:2,tension:.4}));this.exchangeRatesChart=new Chart(this.$refs.exchangeRatesChart.getContext("2d"),{type:"line",options:{plugins:{legend:{display:!0},title:{display:!0,text:"Bitcoin Price History"}}},data:{labels:t,datasets:a}})}}}),window.app.component("lnbits-admin-security",{props:["form-data"],template:"#lnbits-admin-security",data:()=>({logs:[],formBlockedIPs:"",serverlogEnabled:!1,nostrAcceptedUrl:"",formAllowedIPs:"",formCallbackUrlRule:""}),created(){},methods:{addAllowedIPs(){const e=this.formAllowedIPs.trim(),t=this.formData.lnbits_allowed_ips;e&&e.length&&!t.includes(e)&&(this.formData.lnbits_allowed_ips=[...t,e],this.formAllowedIPs="")},removeAllowedIPs(e){const t=this.formData.lnbits_allowed_ips;this.formData.lnbits_allowed_ips=t.filter(t=>t!==e)},addBlockedIPs(){const e=this.formBlockedIPs.trim(),t=this.formData.lnbits_blocked_ips;e&&e.length&&!t.includes(e)&&(this.formData.lnbits_blocked_ips=[...t,e],this.formBlockedIPs="")},removeBlockedIPs(e){const t=this.formData.lnbits_blocked_ips;this.formData.lnbits_blocked_ips=t.filter(t=>t!==e)},addCallbackUrlRule(){const e=this.formCallbackUrlRule.trim(),t=this.formData.lnbits_callback_url_rules;e&&e.length&&!t.includes(e)&&(this.formData.lnbits_callback_url_rules=[...t,e],this.formCallbackUrlRule="")},removeCallbackUrlRule(e){const t=this.formData.lnbits_callback_url_rules;this.formData.lnbits_callback_url_rules=t.filter(t=>t!==e)},addNostrUrl(){const e=this.nostrAcceptedUrl.trim();this.removeNostrUrl(e),this.formData.nostr_absolute_request_urls.push(e),this.nostrAcceptedUrl=""},removeNostrUrl(e){this.formData.nostr_absolute_request_urls=this.formData.nostr_absolute_request_urls.filter(t=>t!==e)},async toggleServerLog(){if(this.serverlogEnabled=!this.serverlogEnabled,this.serverlogEnabled){const e="http:"!==location.protocol?"wss://":"ws://",t=await LNbits.utils.digestMessage(this.g.user.id),a=e+document.domain+":"+location.port+"/api/v1/ws/"+t;this.ws=new WebSocket(a),this.ws.addEventListener("message",async({data:e})=>{this.logs.push(e.toString());const t=this.$refs.logScroll;if(t){const e=t.getScrollTarget(),a=0;t.setScrollPosition(e.scrollHeight,a)}})}else this.ws.close()}}}),window.app.component("lnbits-admin-users",{props:["form-data"],template:"#lnbits-admin-users",data:()=>({formAddUser:"",formAddAdmin:"",formAddActivationCode:"",showReusableActivationCode:!1}),methods:{addAllowedUser(){let e=this.formAddUser,t=this.formData.lnbits_allowed_users;e&&e.length&&!t.includes(e)&&(this.formData.lnbits_allowed_users=[...t,e],this.formAddUser="")},removeAllowedUser(e){let t=this.formData.lnbits_allowed_users;this.formData.lnbits_allowed_users=t.filter(t=>t!==e)},addAdminUser(){let e=this.formAddAdmin,t=this.formData.lnbits_admin_users;e&&e.length&&!t.includes(e)&&(this.formData.lnbits_admin_users=[...t,e],this.formAddAdmin="")},removeAdminUser(e){let t=this.formData.lnbits_admin_users;this.formData.lnbits_admin_users=t.filter(t=>t!==e)},addOneTimeActivationCode(){const e=this.formAddActivationCode,t=this.formData.lnbits_register_one_time_activation_codes;e?.length&&!t.includes(e)&&(this.formData.lnbits_register_one_time_activation_codes=[...t,e],this.formAddActivationCode="")},removeOneTimeActivationCode(e){const t=this.formData.lnbits_register_one_time_activation_codes;this.formData.lnbits_register_one_time_activation_codes=t.filter(t=>t!==e)}}}),window.app.component("lnbits-admin-server",{props:["form-data"],template:"#lnbits-admin-server"}),window.app.component("lnbits-admin-extensions",{props:["form-data"],template:"#lnbits-admin-extensions",data:()=>({formAddExtensionsManifest:""}),methods:{addExtensionsManifest(){const e=this.formAddExtensionsManifest.trim(),t=this.formData.lnbits_extensions_manifests;e&&e.length&&!t.includes(e)&&(this.formData.lnbits_extensions_manifests=[...t,e],this.formAddExtensionsManifest="")},removeExtensionsManifest(e){const t=this.formData.lnbits_extensions_manifests;this.formData.lnbits_extensions_manifests=t.filter(t=>t!==e)}}}),window.app.component("lnbits-admin-notifications",{props:["form-data"],template:"#lnbits-admin-notifications",data:()=>({nostrNotificationIdentifier:"",emailNotificationAddress:""}),methods:{sendTestEmail(){LNbits.api.request("GET","/admin/api/v1/testemail",this.g.user.wallets[0].adminkey).then(e=>{if("error"===e.data.status)throw new Error(e.data.message);this.$q.notify({message:"Test email sent!",color:"positive"})}).catch(e=>{this.$q.notify({message:e.message,color:"negative"})})},addNostrNotificationIdentifier(){const e=this.nostrNotificationIdentifier.trim(),t=this.formData.lnbits_nostr_notifications_identifiers;e&&e.length&&!t.includes(e)&&(this.formData.lnbits_nostr_notifications_identifiers=[...t,e],this.nostrNotificationIdentifier="")},removeNostrNotificationIdentifier(e){const t=this.formData.lnbits_nostr_notifications_identifiers;this.formData.lnbits_nostr_notifications_identifiers=t.filter(t=>t!==e)},addEmailNotificationAddress(){const e=this.emailNotificationAddress.trim(),t=this.formData.lnbits_email_notifications_to_emails;e&&e.length&&!t.includes(e)&&(this.formData.lnbits_email_notifications_to_emails=[...t,e],this.emailNotificationAddress="")},removeEmailNotificationAddress(e){const t=this.formData.lnbits_email_notifications_to_emails;this.formData.lnbits_email_notifications_to_emails=t.filter(t=>t!==e)}}}),window.app.component("lnbits-admin-site-customisation",{props:["form-data"],template:"#lnbits-admin-site-customisation",data:()=>({lnbits_theme_options:["classic","bitcoin","flamingo","cyber","freedom","mint","autumn","monochrome","salvador"],colors:["primary","secondary","accent","positive","negative","info","warning","red","yellow","orange"],reactionOptions:["none","confettiBothSides","confettiFireworks","confettiStars","confettiTop"],globalBorderOptions:["retro-border","hard-border","neon-border","no-border"]}),methods:{onBackgroundImageInput(e){const t=e.target.files[0];t&&this.uploadBackgroundImage(t),e.target.value=null},async uploadBackgroundImage(e){const t=new FormData;t.append("file",e);try{const{data:e}=await LNbits.api.request("POST","/api/v1/assets?public_asset=true",null,t,{headers:{"Content-Type":"multipart/form-data"}}),a=`${window.location.origin}/api/v1/assets/${e.id}/thumbnail`;this.formData.lnbits_default_bgimage=a,Quasar.Notify.create({type:"positive",message:"Background image uploaded.",icon:null})}catch(e){LNbits.utils.notifyApiError(e)}}}}),window.app.component("lnbits-admin-assets-config",{props:["form-data"],template:"#lnbits-admin-assets-config",data:()=>({newAllowedAssetMimeType:"",newNoLimitUser:""}),async created(){},methods:{addAllowedAssetMimeType(){this.newAllowedAssetMimeType&&(this.removeAllowedAssetMimeType(this.newAllowedAssetMimeType),this.formData.lnbits_assets_allowed_mime_types.push(this.newAllowedAssetMimeType),this.newAllowedAssetMimeType="",this.formData.touch=null)},removeAllowedAssetMimeType(e){const t=this.formData.lnbits_assets_allowed_mime_types.indexOf(e);-1!==t&&this.formData.lnbits_assets_allowed_mime_types.splice(t,1),this.formData.touch=null},addNewNoLimitUser(){this.newNoLimitUser&&(this.removeNoLimitUser(this.newNoLimitUser),this.formData.lnbits_assets_no_limit_users.push(this.newNoLimitUser),this.newNoLimitUser="",this.formData.touch=null)},removeNoLimitUser(e){e&&(this.formData.lnbits_assets_no_limit_users=this.formData.lnbits_assets_no_limit_users.filter(t=>t!==e),this.formData.touch=null)}}}),window.app.component("lnbits-admin-audit",{props:["form-data"],template:"#lnbits-admin-audit",data:()=>({formAddIncludePath:"",formAddExcludePath:"",formAddIncludeResponseCode:""}),methods:{addIncludePath(){if(""===this.formAddIncludePath)return;const e=this.formData.lnbits_audit_include_paths;e.includes(this.formAddIncludePath)||(this.formData.lnbits_audit_include_paths=[...e,this.formAddIncludePath]),this.formAddIncludePath=""},removeIncludePath(e){this.formData.lnbits_audit_include_paths=this.formData.lnbits_audit_include_paths.filter(t=>t!==e)},addExcludePath(){if(""===this.formAddExcludePath)return;const e=this.formData.lnbits_audit_exclude_paths;e.includes(this.formAddExcludePath)||(this.formData.lnbits_audit_exclude_paths=[...e,this.formAddExcludePath]),this.formAddExcludePath=""},removeExcludePath(e){this.formData.lnbits_audit_exclude_paths=this.formData.lnbits_audit_exclude_paths.filter(t=>t!==e)},addIncludeResponseCode(){if(""===this.formAddIncludeResponseCode)return;const e=this.formData.lnbits_audit_http_response_codes;e.includes(this.formAddIncludeResponseCode)||(this.formData.lnbits_audit_http_response_codes=[...e,this.formAddIncludeResponseCode]),this.formAddIncludeResponseCode=""},removeIncludeResponseCode(e){this.formData.lnbits_audit_http_response_codes=this.formData.lnbits_audit_http_response_codes.filter(t=>t!==e)}}}),window.app.component("lnbits-wallet-charts",{template:"#lnbits-wallet-charts",props:["paymentFilter","chartConfig"],data:()=>({debounceTimeoutValue:1337,debounceTimeout:null,chartData:[],chartDataPointCount:0,walletBalanceChart:null,walletBalanceInOut:null,walletPaymentInOut:null,colorPrimary:Quasar.colors.changeAlpha(Quasar.colors.getPaletteColor("primary"),.3),colorSecondary:Quasar.colors.changeAlpha(Quasar.colors.getPaletteColor("secondary"),.3),barOptions:{responsive:!0,maintainAspectRatio:!1,scales:{x:{stacked:!0},y:{stacked:!0}}}}),watch:{paymentFilter:{deep:!0,handler(){this.changeCharts()}},chartConfig:{deep:!0,handler(e){this.$q.localStorage.setItem("lnbits.wallets.chartConfig",e),this.changeCharts()}}},methods:{changeCharts(){this.debounceTimeout&&clearTimeout(this.debounceTimeout),this.debounceTimeout=setTimeout(async()=>{await this.fetchChartData(),this.drawCharts()},this.debounceTimeoutValue)},filterChartData(){const e=this.paymentFilter["time[ge]"]+"T00:00:00",t=this.paymentFilter["time[le]"]+"T23:59:59";let a=0,s=this.chartData.map(e=>void 0!==this.paymentFilter["amount[ge]"]?(a+=e.balance_in,{...e,balance:a,balance_out:0,count_out:0}):void 0!==this.paymentFilter["amount[le]"]?(a-=e.balance_out,{...e,balance:a,balance_in:0,count_in:0}):{...e});s=s.filter(a=>this.paymentFilter["time[ge]"]&&this.paymentFilter["time[le]"]?a.date>=e&&a.date<=t:this.paymentFilter["time[ge]"]?a.date>=e:!this.paymentFilter["time[le]"]||a.date<=t);const i=s.map(e=>new Date(e.date).toLocaleString("default",{month:"short",day:"numeric"}));return this.chartDataPointCount=s.length,{data:s,labels:i}},drawBalanceInOutChart(e,t){this.walletBalanceInOut&&this.walletBalanceInOut.destroy();const a=this.$refs.walletBalanceInOut;a&&(this.walletBalanceInOut=new Chart(a.getContext("2d"),{type:"bar",options:this.barOptions,data:{labels:t,datasets:[{label:"Balance In",borderRadius:5,data:e.map(e=>e.balance_in),backgroundColor:this.colorPrimary},{label:"Balance Out",borderRadius:5,data:e.map(e=>e.balance_out),backgroundColor:this.colorSecondary}]}}))},drawPaymentInOut(e,t){this.walletPaymentInOut&&this.walletPaymentInOut.destroy();const a=this.$refs.walletPaymentInOut;a&&(this.walletPaymentInOut=new Chart(a.getContext("2d"),{type:"bar",options:this.barOptions,data:{labels:t,datasets:[{label:"Payments In",data:e.map(e=>e.count_in),backgroundColor:this.colorPrimary},{label:"Payments Out",data:e.map(e=>-e.count_out),backgroundColor:this.colorSecondary}]}}))},drawBalanceChart(e,t){this.walletBalanceChart&&this.walletBalanceChart.destroy();const a=this.$refs.walletBalanceChart;a&&(this.walletBalanceChart=new Chart(a.getContext("2d"),{type:"line",options:{responsive:!0,maintainAspectRatio:!1},data:{labels:t,datasets:[{label:"Balance",data:e.map(e=>e.balance),pointStyle:!1,backgroundColor:this.colorPrimary,borderColor:this.colorPrimary,borderWidth:2,fill:!0,tension:.7,fill:1},{label:"Fees",data:e.map(e=>e.fee),pointStyle:!1,backgroundColor:this.colorSecondary,borderColor:this.colorSecondary,borderWidth:1,fill:!0,tension:.7,fill:1}]}}))},drawCharts(){const{data:e,labels:t}=this.filterChartData();this.chartConfig.showBalanceChart&&this.drawBalanceChart(e,t),this.chartConfig.showBalanceInOutChart&&this.drawBalanceInOutChart(e,t),this.chartConfig.showPaymentInOutChart&&this.drawPaymentInOut(e,t)},async fetchChartData(){try{const{data:e}=await LNbits.api.request("GET",`/api/v1/payments/stats/daily?wallet_id=${this.g.wallet.id}`);this.chartData=e}catch(e){console.warn(e),LNbits.utils.notifyApiError(e)}}},async created(){await this.fetchChartData(),this.drawCharts()}}),window.app.component("lnbits-wallet-api-docs",{template:"#lnbits-wallet-api-docs",methods:{resetKeys(){LNbits.utils.confirmDialog("Are you sure you want to reset your API keys?").onOk(()=>{LNbits.api.resetWalletKeys(this.g.wallet).then(e=>{const{id:t,adminkey:a,inkey:s}=e;this.g.wallet={...this.g.wallet,inkey:s,adminkey:a};const i=this.g.user.wallets.findIndex(e=>e.id===t);-1!==i&&(this.g.user.wallets[i]={...this.g.user.wallets[i],inkey:s,adminkey:a}),Quasar.Notify.create({timeout:3500,type:"positive",message:"API keys reset!"})}).catch(e=>{LNbits.utils.notifyApiError(e)})})}},data:()=>({origin:window.location.origin,inkeyHidden:!0,adminkeyHidden:!0,walletIdHidden:!0})}),window.app.component("lnbits-wallet-icon",{template:"#lnbits-wallet-icon",data:()=>({icon:{show:!1,data:{},colorOptions:["primary","purple","orange","green","brown","blue","red","pink"],options:["home","star","bolt","paid","savings","store","videocam","music_note","flight","train","directions_car","school","construction","science","sports_esports","sports_tennis","theaters","water","headset_mic","videogame_asset","person","group","pets","sunny","elderly","verified","snooze","mail","forum","shopping_cart","shopping_bag","attach_money","print_connect","dark_mode","light_mode","android","network_wifi","shield","fitness_center","lunch_dining"]}}),methods:{setSelectedIcon(e){this.icon.data.icon=e},setSelectedColor(e){this.icon.data.color=e},setIcon(){this.$emit("update-wallet",this.icon.data),this.icon.show=!1}}}),window.app.component("lnbits-wallet-new",{template:"#lnbits-wallet-new",data:()=>({walletTypes:[{label:"Lightning Wallet",value:"lightning"}],wallet:{name:"",sharedWalletId:""},showNewWalletDialog:!1}),watch:{"g.newWalletType"(e){null!==e&&(this.showNewWalletDialog=!0)},showNewWalletDialog(e){!0!==e&&this.reset()}},computed:{isLightning(){return"lightning"===this.g.newWalletType},isLightningShared(){return"lightning-shared"===this.g.newWalletType},inviteWalletOptions(){return(this.g.user?.extra?.wallet_invite_requests||[]).map(e=>({label:`${e.to_wallet_name} (from ${e.from_user_name})`,value:e.to_wallet_id}))}},methods:{reset(){this.showNewWalletDialog=!1,this.g.newWalletType=null,this.wallet={name:"",sharedWalletId:""}},async submitRejectWalletInvitation(){try{const e=this.g.user.extra.wallet_invite_requests||[],t=e.find(e=>e.to_wallet_id===this.wallet.sharedWalletId);if(!t)return void Quasar.Notify.create({message:"Cannot find invitation for the selected wallet.",type:"warning"});await LNbits.api.request("DELETE",`/api/v1/wallet/share/invite/${t.request_id}`,this.g.wallet.adminkey),Quasar.Notify.create({message:"Invitation rejected.",type:"positive"}),this.g.user.extra.wallet_invite_requests=e.filter(e=>e.request_id!==t.request_id)}catch(e){LNbits.utils.notifyApiError(e)}},submitAddWallet(){const e=this.wallet;"lightning"!==this.g.newWalletType||e.name?"lightning-shared"!==this.g.newWalletType||e.sharedWalletId?LNbits.api.createWallet(e.name,this.g.newWalletType,{shared_wallet_id:e.sharedWalletId}).then(e=>{this.$q.notify({message:"Wallet created successfully",color:"positive"}),this.reset(),this.g.user.wallets.push(LNbits.map.wallet(e.data)),this.g.lastWalletId=e.data.id,this.$router.push(`/wallet/${e.data.id}`)}).catch(LNbits.utils.notifyApiError):this.$q.notify({message:"Missing a shared wallet ID",color:"warning"}):this.$q.notify({message:"Please enter a name for the wallet",color:"warning"})}},created(){this.g.user?.extra?.wallet_invite_requests?.length&&this.walletTypes.push({label:`Lightning Wallet (Share Invite: ${this.g.user.extra.wallet_invite_requests.length})`,value:"lightning-shared"})}}),window.app.component("lnbits-wallet-share",{template:"#lnbits-wallet-share",computed:{walletApprovedShares(){return this.g.wallet.extra.shared_with.filter(e=>"approved"===e.status)},walletPendingRequests(){return this.g.wallet.extra.shared_with.filter(e=>"request_access"===e.status)},walletPendingInvites(){return this.g.wallet.extra.shared_with.filter(e=>"invite_sent"===e.status)}},data:()=>({permissionOptions:[{label:"View",value:"view-payments"},{label:"Receive",value:"receive-payments"},{label:"Send",value:"send-payments"}],walletShareInvite:{username:"",permissions:[]}}),methods:{async updateSharePermissions(e){try{const{data:t}=await LNbits.api.request("PUT","/api/v1/wallet/share",this.g.wallet.adminkey,e);Object.assign(e,t),Quasar.Notify.create({message:"Wallet permission updated.",type:"positive"})}catch(e){LNbits.utils.notifyApiError(e)}},async inviteUserToWallet(){try{const{data:e}=await LNbits.api.request("PUT","/api/v1/wallet/share/invite",this.g.wallet.adminkey,{...this.walletShareInvite,status:"invite_sent",wallet_id:this.g.wallet.id});this.g.wallet.extra.shared_with.push(e),this.walletShareInvite={username:"",permissions:[]},Quasar.Notify.create({message:"User invited to wallet.",type:"positive"})}catch(e){LNbits.utils.notifyApiError(e)}},deleteSharePermission(e){LNbits.utils.confirmDialog("Are you sure you want to remove this share permission?").onOk(async()=>{try{await LNbits.api.request("DELETE",`/api/v1/wallet/share/${e.request_id}`,this.g.wallet.adminkey),this.g.wallet.extra.shared_with=this.g.wallet.extra.shared_with.filter(t=>t.wallet_id!==e.wallet_id),Quasar.Notify.create({message:"Wallet permission deleted.",type:"positive"})}catch(e){LNbits.utils.notifyApiError(e)}})}}}),window.app.component("lnbits-wallet-paylinks",{template:"#lnbits-wallet-paylinks",data:()=>({storedPaylinks:[]}),watch:{"g.wallet"(e){this.storedPaylinks=e.storedPaylinks??[]}},created(){this.storedPaylinks=this.g.wallet.storedPaylinks},methods:{updatePaylinks(){LNbits.api.request("PUT",`/api/v1/wallet/stored_paylinks/${this.g.wallet.id}`,this.g.wallet.adminkey,{links:this.storedPaylinks}).then(()=>{this.$q.notify({message:"Paylinks updated.",type:"positive",timeout:3500})}).catch(e=>{LNbits.utils.notifyApiError(e)})},sendToPaylink(e){this.$emit("send-lnurl",e)},editPaylink(){this.$nextTick(()=>{this.updatePaylinks()})},deletePaylink(e){const t=[];this.storedPaylinks.forEach(a=>{a.lnurl!==e&&t.push(a)}),this.storedPaylinks=t,this.updatePaylinks()}}}),window.app.component("lnbits-wallet-extra",{template:"#lnbits-wallet-extra",props:["chartConfig"],computed:{exportUrl(){return`${window.location.origin}/wallet?usr=${this.g.user.id}&wal=${this.g.wallet.id}`}},methods:{handleSendLnurl(e){this.$emit("send-lnurl",e)},updateWallet(e){this.$emit("update-wallet",e)},handleFiatTracking(){this.g.fiatTracking=!this.g.fiatTracking,this.g.fiatTracking?(this.updateWallet({currency:this.g.wallet.currency}),this.updateFiatBalance()):(this.g.isFiatPriority=!1,this.g.wallet.currency="",this.updateWallet({currency:""}))},deleteWallet(){LNbits.utils.confirmDialog("Are you sure you want to delete this wallet?").onOk(()=>{LNbits.api.deleteWallet(this.g.wallet).then(()=>{this.g.user.wallets=this.g.user.wallets.filter(e=>e.id!==this.g.wallet.id),this.g.lastActiveWallet=this.g.user.wallets[0].id,this.$router.push(`/wallet/${this.g.lastActiveWallet}`),Quasar.Notify.create({timeout:3e3,message:"Wallet deleted!",spinner:!0})}).catch(e=>{LNbits.utils.notifyApiError(e)})})},updateFiatBalance(){this.$q.localStorage.getItem("lnbits.exchangeRate."+this.g.wallet.currency)&&(this.g.exchangeRate=this.$q.localStorage.getItem("lnbits.exchangeRate."+this.g.wallet.currency),this.g.fiatBalance=this.g.exchangeRate/1e8*this.g.wallet.sat),LNbits.api.request("GET","/api/v1/rate/"+this.g.wallet.currency,null).then(e=>{this.g.fiatBalance=e.data.price/1e8*this.g.wallet.sat,this.g.exchangeRate=e.data.price.toFixed(2),this.g.fiatTracking=!0,this.$q.localStorage.set("lnbits.exchangeRate."+this.g.wallet.currency,this.g.exchangeRate),this.g.exchangeRate<=0&&(this.g.fiatTracking=!1,this.g.isFiatPriority=!1)}).catch(e=>console.error(e))}},created(){""!==this.g.wallet.currency&&this.g.isSatsDenomination?(this.g.fiatTracking=!0,this.updateFiatBalance()):this.g.fiatTracking=!1}}),window.app.component("lnbits-home-logos",{template:"#lnbits-home-logos",data:()=>({logos:[{href:"https://github.com/ElementsProject/lightning",lightSrc:"/static/images/clnl.png",darkSrc:"/static/images/cln.png"},{href:"https://github.com/lightningnetwork/lnd",lightSrc:"/static/images/lnd.png",darkSrc:"/static/images/lnd.png"},{href:"https://opennode.com",lightSrc:"/static/images/opennodel.png",darkSrc:"/static/images/opennode.png"},{href:"https://lnpay.co/",lightSrc:"/static/images/lnpayl.png",darkSrc:"/static/images/lnpay.png"},{href:"https://github.com/rootzoll/raspiblitz",lightSrc:"/static/images/blitzl.png",darkSrc:"/static/images/blitz.png"},{href:"https://start9.com/",lightSrc:"/static/images/start9l.png",darkSrc:"/static/images/start9.png"},{href:"https://getumbrel.com/",lightSrc:"/static/images/umbrell.png",darkSrc:"/static/images/umbrel.png"},{href:"https://mynodebtc.com",lightSrc:"/static/images/mynodel.png",darkSrc:"/static/images/mynode.png"},{href:"https://github.com/shesek/spark-wallet",lightSrc:"/static/images/sparkl.png",darkSrc:"/static/images/spark.png"},{href:"https://voltage.cloud",lightSrc:"/static/images/voltagel.png",darkSrc:"/static/images/voltage.png"},{href:"https://breez.technology/sdk/",lightSrc:"/static/images/breezl.png",darkSrc:"/static/images/breez.png"},{href:"https://blockstream.com/lightning/greenlight/",lightSrc:"/static/images/greenlightl.png",darkSrc:"/static/images/greenlight.png"},{href:"https://getalby.com",lightSrc:"/static/images/albyl.png",darkSrc:"/static/images/alby.png"},{href:"https://zbd.gg",lightSrc:"/static/images/zbdl.png",darkSrc:"/static/images/zbd.png"},{href:"https://phoenix.acinq.co/server",lightSrc:"/static/images/phoenixdl.png",darkSrc:"/static/images/phoenixd.png"},{href:"https://boltz.exchange/",lightSrc:"/static/images/boltzl.svg",darkSrc:"/static/images/boltz.svg"},{href:"https://www.blink.sv/",lightSrc:"/static/images/blink_logol.png",darkSrc:"/static/images/blink_logo.png"}]}),computed:{showLogos(){return this.g.isSatsDenomination&&"LNbits"==this.g.settings.siteTitle&&1==this.g.settings.showHomePageElements}}}),window.app.component("lnbits-error",{template:"#lnbits-error",props:["dynamic","code","message"],computed:{isExtension(){return 403==this.code&&(!!this.message.startsWith("Extension ")||void 0)}},methods:{goBack(){window.history.back()},goHome(){window.location="/"},goToWallet(){this.dynamic?this.$router.push("/wallet"):window.location="/wallet"},goToExtension(){const e=`/extensions#${this.message.match(/'([^']+)'/)[1]}`;this.dynamic?this.$router.push(e):window.location=e},async logOut(){try{await LNbits.api.logout(),window.location="/"}catch(e){LNbits.utils.notifyApiError(e)}}},async created(){if(!this.dynamic&&401==this.code)return console.warn(`Unauthorized: ${this.errorMessage}`),void this.logOut()}}),window.app.component("lnbits-qrcode",{template:"#lnbits-qrcode",components:{QrcodeVue:QrcodeVue.default},props:{value:{type:String,required:!0},nfc:{type:Boolean,default:!1},print:{type:Boolean,default:!1},showButtons:{type:Boolean,default:!0},href:{type:String,default:""},margin:{type:Number,default:3},maxWidth:{type:Number,default:450},logo:{type:String,default:window.g.settings.qrLogo||null}},data:()=>({nfcTagWriting:!1,nfcSupported:"undefined"!=typeof NDEFReader}),methods:{printQrCode(){const e=this.$refs.qrCode.$el.outerHTML,t=window.open("","_blank");t.document.write(`\n \n
\n