Compare commits
107
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b6f453388 | ||
|
|
307100fabe | ||
|
|
32379687ab | ||
|
|
aeb7735e51 | ||
|
|
2ef3894f74 | ||
|
|
73f2c863eb | ||
|
|
d1be79052f | ||
|
|
28341dedfa | ||
|
|
0b89c75386 | ||
|
|
e1827e33ad | ||
|
|
ff109aab87 | ||
|
|
84dfb5b3e9 | ||
|
|
ef5ac2af64 | ||
|
|
b13b3b9b92 | ||
|
|
053671b10f | ||
|
|
bf146c82b3 | ||
|
|
44dd0e1570 | ||
|
|
9d6069a043 | ||
|
|
e0fc3c89d1 | ||
|
|
77606a9fc1 | ||
|
|
1d5c6ac601 | ||
|
|
95adf7bfbb | ||
|
|
d13046efbf | ||
|
|
0d969ec624 | ||
|
|
3150ae7be1 | ||
|
|
4b8fca43cf | ||
|
|
a0901a33ca | ||
|
|
0e30dbc2af | ||
|
|
5d231b0c9e | ||
|
|
90600da5a5 | ||
|
|
8fee8425a8 | ||
|
|
28024e0608 | ||
|
|
236f8e44b6 | ||
|
|
f3cdf85f3b | ||
|
|
da412a2321 | ||
|
|
385b3920d4 | ||
|
|
6887d949c7 | ||
|
|
ab9e68741d | ||
|
|
17a7b5abab | ||
|
|
9abced4eee | ||
|
|
2a8a878fea | ||
|
|
d1ebfdddac | ||
|
|
118fbd46f3 | ||
|
|
233d2c5e71 | ||
|
|
daf088b4ec | ||
|
|
1b03d8195d | ||
|
|
84e68e0fc3 | ||
|
|
c3fb049fa6 | ||
|
|
487af32ccb | ||
|
|
f20bd17321 | ||
|
|
368dcf1231 | ||
|
|
e1437c0c0f | ||
|
|
8135b15404 | ||
|
|
62c651b3f2 | ||
|
|
d5ee044454 | ||
|
|
55f244ed4e | ||
|
|
727d5fec8d | ||
|
|
feb90088ec | ||
|
|
63050dbbbd | ||
|
|
4cba73c183 | ||
|
|
d1b795556e | ||
|
|
21f88d15e8 | ||
|
|
46e31e35cd | ||
|
|
6cd2746078 | ||
|
|
b4ebbc36fe | ||
|
|
c519d86e57 | ||
|
|
b28d2bd9b1 | ||
|
|
2b791d1c23 | ||
|
|
b74b75d303 | ||
|
|
f7991e5231 | ||
|
|
e5e14ca2ad | ||
|
|
97a1a0b303 | ||
|
|
9c0035c01d | ||
|
|
7614c83fe8 | ||
|
|
21a517e34f | ||
|
|
f42f973032 | ||
|
|
49a0bd9a1d | ||
|
|
8a726ddaa0 | ||
|
|
c4ee44ed4b | ||
|
|
5fe4752ebf | ||
|
|
faa2b930ba | ||
|
|
fbc39bf423 | ||
|
|
d6236964be | ||
|
|
60e4952b7f | ||
|
|
412736631c | ||
|
|
5187731c76 | ||
|
|
4a3d019660 | ||
|
|
6e2099cb4f | ||
|
|
07327a5b57 | ||
|
|
24b76f67aa | ||
|
|
7a071163cc | ||
|
|
1a5dc0da67 | ||
|
|
4444f7a897 | ||
|
|
516b01c6eb | ||
|
|
73bc5ddc16 | ||
|
|
814d7bf45b | ||
|
|
5e17e5e13d | ||
|
|
6223100591 | ||
|
|
989cd08614 | ||
|
|
c426c23567 | ||
|
|
23b22e89c7 | ||
|
|
8425e7467e | ||
|
|
42afbc0576 | ||
|
|
937b9cb5f1 | ||
|
|
73c4d1b409 | ||
|
|
04a6ed5ba1 | ||
|
|
e39384b3c8 |
+1
-1
@@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["sh", "-c", "uv --offline run --no-sync lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||
CMD ["sh", "-c", "uv --offline run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||
|
||||
+1
-1
@@ -531,7 +531,7 @@ def register_async_tasks() -> None:
|
||||
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")
|
||||
core_app_extra.dispatch_extension_invoice_paid = dispatch_extension_invoice_paid
|
||||
|
||||
# server logs for websocket
|
||||
if settings.lnbits_admin_ui:
|
||||
|
||||
@@ -18,13 +18,13 @@ 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 (
|
||||
DateTrunc,
|
||||
check_internal,
|
||||
create_payment,
|
||||
delete_expired_invoices,
|
||||
delete_wallet_payment,
|
||||
get_latest_payments_by_extension,
|
||||
get_payment,
|
||||
@@ -102,6 +102,7 @@ __all__ = [
|
||||
"delete_accounts_no_wallets",
|
||||
"delete_admin_settings",
|
||||
"delete_dbversion",
|
||||
"delete_expired_invoices",
|
||||
"delete_installed_extension",
|
||||
"delete_tinyurl",
|
||||
"delete_unused_wallets",
|
||||
@@ -155,7 +156,6 @@ __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",
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
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
|
||||
|
||||
@@ -16,11 +11,6 @@ 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(
|
||||
@@ -28,11 +18,6 @@ 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(
|
||||
@@ -46,33 +31,6 @@ 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:
|
||||
@@ -186,158 +144,3 @@ 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)},
|
||||
)
|
||||
|
||||
@@ -238,6 +238,32 @@ async def get_payments_status_count() -> PaymentsStatusCount:
|
||||
)
|
||||
|
||||
|
||||
async def delete_expired_invoices(
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
# first we delete all invoices older than one month
|
||||
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE status = :status AND amount > 0
|
||||
AND time < {db.timestamp_placeholder("delta")}
|
||||
""", # noqa: S608
|
||||
{"status": f"{PaymentState.PENDING}", "delta": int(time() - 2592000)},
|
||||
)
|
||||
# then we delete all invoices whose expiry date is in the past
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE status = :status AND amount > 0
|
||||
AND expiry < {db.timestamp_placeholder("now")}
|
||||
""", # noqa: S608
|
||||
{"status": f"{PaymentState.PENDING}", "now": int(time())},
|
||||
)
|
||||
|
||||
|
||||
async def create_payment(
|
||||
checking_id: str,
|
||||
data: CreatePayment,
|
||||
|
||||
@@ -824,69 +824,3 @@ async def m046_add_permissions_to_installed_extensions(db: Connection):
|
||||
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 '{}'")
|
||||
|
||||
@@ -7,15 +7,12 @@ import os
|
||||
import shutil
|
||||
import zipfile
|
||||
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, StrictStr
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.helpers import (
|
||||
download_url,
|
||||
@@ -82,13 +79,11 @@ class GitHubRepo(BaseModel):
|
||||
|
||||
|
||||
class ExtensionPermission(BaseModel):
|
||||
id: StrictStr
|
||||
description: StrictStr | None = None
|
||||
id: str
|
||||
label: str | None = None
|
||||
description: str | None = None
|
||||
policies: list[Any] | None = None
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
@staticmethod
|
||||
def list_from_config(config_json: Mapping[str, Any]) -> list[ExtensionPermission]:
|
||||
return [
|
||||
@@ -141,83 +136,11 @@ 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:
|
||||
@@ -273,63 +196,6 @@ class Extension(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -505,7 +371,6 @@ class InstallableExtension(BaseModel):
|
||||
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:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
@@ -11,10 +10,17 @@ def _do_nothing(*_):
|
||||
pass
|
||||
|
||||
|
||||
async def _do_nothing_async(_: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class CoreAppExtra:
|
||||
register_new_ext_routes: Callable = _do_nothing
|
||||
register_new_wasm_ext_routes: Callable = _do_nothing
|
||||
register_new_ratelimiter: Callable
|
||||
dispatch_extension_invoice_paid: Callable[[Any], Awaitable[None]] = (
|
||||
_do_nothing_async
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.wasm_extension_registry = WasmExtensionRegistry()
|
||||
@@ -25,16 +31,8 @@ class WasmExtensionRegistry:
|
||||
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)
|
||||
|
||||
@@ -42,13 +40,6 @@ class WasmExtensionRegistry:
|
||||
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"
|
||||
amount: float
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
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
|
||||
|
||||
@@ -20,168 +13,22 @@ 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 WasmRuntimeLimits, settings
|
||||
from lnbits.settings import settings
|
||||
|
||||
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,
|
||||
@@ -200,8 +47,6 @@ 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)
|
||||
|
||||
@@ -245,394 +90,6 @@ 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)
|
||||
|
||||
@@ -656,8 +113,6 @@ async def activate_extension(ext: Extension):
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -17,7 +17,7 @@ from lnbits.db import Connection, Filters
|
||||
from lnbits.decorators import check_user_extension_access
|
||||
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.helpers import check_callback_url, daystart_timestamp
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.settings import settings
|
||||
from lnbits.task_manager import task_manager
|
||||
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
|
||||
@@ -374,13 +374,6 @@ async def update_pending_payments(wallet_id: str):
|
||||
async def update_pending_payment(
|
||||
payment: Payment, conn: Connection | None = None
|
||||
) -> Payment:
|
||||
if payment.is_in and payment.is_expired:
|
||||
payment.status = PaymentState.FAILED
|
||||
payment.labels.append("expired")
|
||||
await update_payment(payment, conn=conn)
|
||||
logger.info(f"invoice {payment.checking_id} expired, marked as failed")
|
||||
return payment
|
||||
|
||||
status = await check_payment_status(payment)
|
||||
if status.failed:
|
||||
payment.status = PaymentState.FAILED
|
||||
@@ -564,9 +557,10 @@ async def check_wallet_daily_withdraw_limit(
|
||||
raise ValueError("It is not allowed to spend funds from this server.")
|
||||
|
||||
payments = await get_payments(
|
||||
since=daystart_timestamp(),
|
||||
since=int(time.time()) - 60 * 60 * 24,
|
||||
outgoing=True,
|
||||
wallet_id=wallet_id,
|
||||
limit=1,
|
||||
conn=conn,
|
||||
)
|
||||
if len(payments) == 0:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
from http import HTTPStatus
|
||||
@@ -10,7 +9,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_wallet, get_wallets_ids
|
||||
from lnbits.core.crud.wallets import get_wallets_ids
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import (
|
||||
SimpleStatus,
|
||||
@@ -19,30 +18,17 @@ 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
|
||||
@@ -50,17 +36,10 @@ 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
|
||||
@@ -89,9 +68,6 @@ 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):
|
||||
@@ -156,106 +132,6 @@ 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,
|
||||
@@ -379,213 +255,6 @@ 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:
|
||||
@@ -1003,178 +672,3 @@ 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
|
||||
)
|
||||
|
||||
@@ -189,9 +189,6 @@ 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)]
|
||||
)
|
||||
@@ -199,9 +196,7 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
|
||||
"/extensions/builder/preview", dependencies=[Depends(check_extension_builder)]
|
||||
)
|
||||
async def index(
|
||||
request: Request,
|
||||
ext_id: str | None = None,
|
||||
user: User = Depends(check_user_exists),
|
||||
request: Request, user: User = Depends(check_user_exists)
|
||||
) -> HTMLResponse:
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
|
||||
@@ -158,6 +158,10 @@ async def api_update_user(
|
||||
async def api_users_delete_user(
|
||||
user_id: str, account: Account = Depends(check_admin)
|
||||
) -> SimpleStatus:
|
||||
wallets = await get_wallets(user_id, deleted=False)
|
||||
for wallet in wallets:
|
||||
await delete_wallet_by_id(wallet.id)
|
||||
|
||||
if user_id == settings.super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@@ -169,11 +173,6 @@ async def api_users_delete_user(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Only super_user can delete admin user.",
|
||||
)
|
||||
|
||||
wallets = await get_wallets(user_id, deleted=False)
|
||||
for wallet in wallets:
|
||||
await delete_wallet_by_id(wallet.id)
|
||||
|
||||
await delete_account(user_id)
|
||||
return SimpleStatus(success=True, message="User deleted.")
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
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)
|
||||
@@ -17,11 +17,6 @@ from ..storage.crud import (
|
||||
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,
|
||||
@@ -36,7 +31,6 @@ from .models import (
|
||||
NowResponse,
|
||||
PayInvoiceRequest,
|
||||
PayInvoiceResponse,
|
||||
PayLnurlRequest,
|
||||
RandomIdRequest,
|
||||
RandomIdResponse,
|
||||
StorageDeleteRequest,
|
||||
@@ -66,8 +60,6 @@ class ExtensionHostAPI:
|
||||
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)
|
||||
@@ -75,15 +67,9 @@ class ExtensionHostAPI:
|
||||
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(),
|
||||
)
|
||||
self.utils = ExtensionAPIUtils(self.extension_id, self.permissions)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="storage.get",
|
||||
@@ -372,6 +358,7 @@ class ExtensionHostAPI:
|
||||
host_name="pay_invoice",
|
||||
sdk_name="payInvoice",
|
||||
description="Pay a Lightning invoice from a wallet available to the user.",
|
||||
required_permission="wallet.pay_invoice",
|
||||
)
|
||||
async def wallet_pay_invoice(
|
||||
self, request: PayInvoiceRequest
|
||||
@@ -380,135 +367,37 @@ class ExtensionHostAPI:
|
||||
from lnbits.core.services.payments import pay_invoice
|
||||
from lnbits.exceptions import PaymentError
|
||||
|
||||
if not self.user_id:
|
||||
raise PermissionError(
|
||||
"Paying an invoice requires an authenticated user context."
|
||||
)
|
||||
|
||||
wallet = await get_wallet(request.wallet_id)
|
||||
if wallet is None:
|
||||
if wallet is None or wallet.user != self.user_id:
|
||||
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),
|
||||
payment_request=request.payment_request,
|
||||
max_sat=request.max_sat,
|
||||
extra=extra,
|
||||
description=request.description or lnurl_pay_response_text(res),
|
||||
extra={"tag": self.extension_id, **request.extra},
|
||||
description=request.description,
|
||||
tag=self.extension_id,
|
||||
)
|
||||
except (
|
||||
LnAddressError,
|
||||
LnurlResponseException,
|
||||
PaymentError,
|
||||
PermissionError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
except (PaymentError, ValueError) as exc:
|
||||
return PayInvoiceResponse(ok=False, error=str(exc))
|
||||
|
||||
return _pay_invoice_response(payment)
|
||||
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,
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="http.request",
|
||||
@@ -524,15 +413,7 @@ class ExtensionHostAPI:
|
||||
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"
|
||||
),
|
||||
)
|
||||
return await send_extension_http_request(self.extension_id, policies, request)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="extension.api.request",
|
||||
@@ -553,10 +434,6 @@ class ExtensionHostAPI:
|
||||
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(
|
||||
@@ -703,16 +580,3 @@ class ExtensionHostAPI:
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
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)
|
||||
@@ -181,17 +181,6 @@ class PayInvoiceRequest(BaseModel):
|
||||
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
|
||||
@@ -293,22 +282,6 @@ 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
|
||||
|
||||
@@ -3,18 +3,6 @@ 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(
|
||||
@@ -32,7 +20,7 @@ def validate_extension_permissions(
|
||||
unknown_ids.append(permission.id)
|
||||
if strict:
|
||||
continue
|
||||
normalized_permissions.append(permission.copy())
|
||||
normalized_permissions.append(permission.copy(update={"label": None}))
|
||||
|
||||
if unknown_ids and strict:
|
||||
raise ValueError(
|
||||
@@ -46,17 +34,14 @@ def validate_extension_permissions(
|
||||
def validate_wasm_extension_permissions(
|
||||
ext_info: InstallableExtension,
|
||||
granted_permissions: list[ExtensionPermission] | None,
|
||||
extension_config: dict[str, Any] | WasmExtensionConfig,
|
||||
extension_config: dict[str, Any],
|
||||
) -> list[ExtensionPermission]:
|
||||
if isinstance(extension_config, WasmExtensionConfig):
|
||||
config = extension_config
|
||||
elif extension_config.get("extension_type") != "wasm":
|
||||
if 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
|
||||
ext_info.id,
|
||||
ExtensionPermission.list_from_config(extension_config),
|
||||
)
|
||||
if not requested_permissions:
|
||||
return []
|
||||
@@ -64,187 +49,11 @@ def validate_wasm_extension_permissions(
|
||||
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:
|
||||
requested_ids = {permission.id for permission in requested_permissions}
|
||||
granted_ids = {permission.id for permission in granted_permissions}
|
||||
if requested_ids != granted_ids:
|
||||
raise ValueError(
|
||||
f"Extension '{ext_info.id}' was granted unrequested permissions: "
|
||||
+ ", ".join(extra_granted_ids)
|
||||
f"Extension '{ext_info.id}' was not granted all requested permissions."
|
||||
)
|
||||
|
||||
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
|
||||
return requested_permissions
|
||||
|
||||
@@ -10,12 +10,7 @@ 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",
|
||||
}
|
||||
_EXTENSION_RUNTIME_PERMISSION_IDS = {"ui.camera.scan_qr"}
|
||||
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
|
||||
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
|
||||
|
||||
|
||||
@@ -31,9 +31,6 @@ class ExtensionAPIHost:
|
||||
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)
|
||||
|
||||
@@ -5,9 +5,6 @@ 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
|
||||
@@ -18,12 +15,6 @@ from lnbits.utils.exchange_rates import (
|
||||
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,
|
||||
@@ -39,8 +30,6 @@ from .models import (
|
||||
InvoiceExpiryResponse,
|
||||
InvoiceMemoResponse,
|
||||
InvoicePaymentHashResponse,
|
||||
LnurlResolveRequest,
|
||||
LnurlResolveResponse,
|
||||
RandomSecretAndHashRequest,
|
||||
RandomSecretAndHashResponse,
|
||||
SatsToFiatRequest,
|
||||
@@ -54,39 +43,17 @@ from .registry import extension_api_method
|
||||
|
||||
|
||||
class ExtensionAPIUtils:
|
||||
def __init__(
|
||||
self,
|
||||
extension_id: str,
|
||||
permissions: Iterable[str],
|
||||
*,
|
||||
authenticated: bool = False,
|
||||
) -> None:
|
||||
def __init__(self, extension_id: str, permissions: Iterable[str]) -> 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
|
||||
)
|
||||
self.currencies = ExtensionCurrencyUtils(extension_id, permission_set)
|
||||
self.server = ExtensionServerUtils(extension_id, permission_set)
|
||||
self.lightning = ExtensionLightningUtils(extension_id, permission_set)
|
||||
|
||||
|
||||
class _ExtensionAPIUtilsGroup:
|
||||
def __init__(
|
||||
self,
|
||||
extension_id: str,
|
||||
permissions: Iterable[str],
|
||||
*,
|
||||
authenticated: bool = False,
|
||||
) -> None:
|
||||
def __init__(self, extension_id: str, permissions: Iterable[str]) -> 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:
|
||||
@@ -95,7 +62,7 @@ class _ExtensionAPIUtilsGroup:
|
||||
)
|
||||
|
||||
def has_authenticated_context(self) -> bool:
|
||||
return self.authenticated
|
||||
return False
|
||||
|
||||
|
||||
class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
|
||||
@@ -226,60 +193,6 @@ class ExtensionServerUtils(_ExtensionAPIUtilsGroup):
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
@@ -422,7 +335,6 @@ def extension_api_utils_method_classes() -> dict[str, type[_ExtensionAPIUtilsGro
|
||||
return {
|
||||
"utils.currencies": ExtensionCurrencyUtils,
|
||||
"utils.server": ExtensionServerUtils,
|
||||
"utils.lnurl": ExtensionLnurlUtils,
|
||||
"utils.lightning": ExtensionLightningUtils,
|
||||
}
|
||||
|
||||
|
||||
@@ -35,9 +35,6 @@ async def send_extension_api_request(
|
||||
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.")
|
||||
@@ -58,7 +55,7 @@ async def send_extension_api_request(
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=False,
|
||||
timeout=_timeout_seconds(timeout_ms, EXTENSION_API_TIMEOUT_SECONDS),
|
||||
timeout=EXTENSION_API_TIMEOUT_SECONDS,
|
||||
trust_env=False,
|
||||
) as client:
|
||||
async with client.stream(
|
||||
@@ -67,10 +64,7 @@ async def send_extension_api_request(
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
content=body,
|
||||
) as response:
|
||||
response_body = await _read_limited_response(
|
||||
response,
|
||||
max_response_bytes=max_response_bytes,
|
||||
)
|
||||
response_body = await _read_limited_response(response)
|
||||
return HttpResponse(
|
||||
status_code=response.status_code,
|
||||
headers=_response_headers(dict(response.headers)),
|
||||
@@ -183,34 +177,17 @@ def _extension_api_path(path: str) -> str:
|
||||
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
|
||||
)
|
||||
async def _read_limited_response(response: httpx.Response) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
size += len(chunk)
|
||||
if limit > 0 and size > limit:
|
||||
if size > EXTENSION_API_MAX_RESPONSE_BYTES:
|
||||
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
|
||||
|
||||
@@ -32,9 +32,6 @@ 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)
|
||||
@@ -52,7 +49,7 @@ async def send_extension_http_request(
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=False,
|
||||
timeout=_timeout_seconds(timeout_ms, HTTP_REQUEST_TIMEOUT_SECONDS),
|
||||
timeout=HTTP_REQUEST_TIMEOUT_SECONDS,
|
||||
trust_env=False,
|
||||
) as client:
|
||||
async with client.stream(
|
||||
@@ -61,10 +58,7 @@ async def send_extension_http_request(
|
||||
headers=headers,
|
||||
content=body,
|
||||
) as response:
|
||||
response_body = await _read_limited_response(
|
||||
response,
|
||||
max_response_bytes=max_response_bytes,
|
||||
)
|
||||
response_body = await _read_limited_response(response)
|
||||
return HttpResponse(
|
||||
status_code=response.status_code,
|
||||
headers=_response_headers(dict(response.headers)),
|
||||
@@ -170,32 +164,17 @@ def _request_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||
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
|
||||
)
|
||||
async def _read_limited_response(response: httpx.Response) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
size += len(chunk)
|
||||
if limit > 0 and size > limit:
|
||||
if size > HTTP_MAX_RESPONSE_BYTES:
|
||||
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
|
||||
|
||||
@@ -2,46 +2,32 @@ 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:
|
||||
for route_config in extension.config.get("api_routes") or []:
|
||||
_add_wasm_extension_api_route(app, extension, route_config)
|
||||
|
||||
|
||||
def _add_wasm_extension_api_route(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
route_config: WasmAPIRouteConfig,
|
||||
route_config: dict[str, Any],
|
||||
) -> 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)
|
||||
method = _wasm_extension_api_method(extension, route_config.get("method"))
|
||||
route_path = _wasm_extension_api_path(extension, route_config.get("path"))
|
||||
export_name = _wasm_extension_api_export(extension, route_config.get("export"))
|
||||
path_params = route_config.get("path_params") or {}
|
||||
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
|
||||
|
||||
if _has_route(app, route_path, method):
|
||||
return
|
||||
@@ -52,27 +38,14 @@ def _add_wasm_extension_api_route(
|
||||
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"],
|
||||
)
|
||||
payload = await _read_api_payload(request, path_params)
|
||||
return await invoke_wasm_extension_export(
|
||||
extension.id,
|
||||
export_name,
|
||||
payload.data,
|
||||
payload,
|
||||
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:
|
||||
@@ -106,70 +79,22 @@ def _add_wasm_extension_api_route(
|
||||
async def _read_api_payload(
|
||||
request: Request,
|
||||
path_params: dict[str, str],
|
||||
*,
|
||||
max_body_bytes: int,
|
||||
) -> WasmRoutePayload:
|
||||
) -> dict[str, Any]:
|
||||
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)
|
||||
payload.update(await _read_json_object(request))
|
||||
return payload
|
||||
|
||||
|
||||
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)
|
||||
async def _read_json_object(request: Request) -> dict[str, Any]:
|
||||
body = await request.body()
|
||||
if not body:
|
||||
return {}, 0
|
||||
return {}
|
||||
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)
|
||||
return value
|
||||
|
||||
|
||||
def _read_api_path_params(
|
||||
@@ -187,32 +112,14 @@ 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:
|
||||
if export.get("name") != export_name:
|
||||
continue
|
||||
if export.visibility in {"public", "authenticated"}:
|
||||
if export.get("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}'.")
|
||||
|
||||
@@ -15,7 +15,6 @@ 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)
|
||||
|
||||
@@ -17,7 +17,6 @@ from lnbits.decorators import (
|
||||
|
||||
from ..wasm.loader import WasmExtension
|
||||
from .api import (
|
||||
WasmRequestBodyTooLargeError,
|
||||
_has_route,
|
||||
_path_template_pattern,
|
||||
_read_json_object,
|
||||
@@ -38,11 +37,13 @@ from .security import (
|
||||
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)
|
||||
for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
|
||||
route_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
|
||||
entrypoint = _wasm_extension_entrypoint(
|
||||
extension, route_config.get("entrypoint")
|
||||
)
|
||||
frame_path = f"/ext-frame/{extension.id}/{route_index}"
|
||||
auth = _wasm_extension_route_auth(extension, route_config.auth)
|
||||
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
|
||||
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
|
||||
_add_wasm_extension_wrapper_route(
|
||||
app,
|
||||
@@ -67,8 +68,6 @@ def _add_wasm_extension_frame_config_route(
|
||||
) -> 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
|
||||
|
||||
@@ -189,13 +188,13 @@ def _wasm_extension_bridge_api_routes(
|
||||
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)
|
||||
for route_config in extension.config.get("api_routes") or []:
|
||||
auth = _wasm_extension_route_auth(extension, route_config.get("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)
|
||||
method = _wasm_extension_api_method(extension, route_config.get("method"))
|
||||
path = _wasm_extension_api_path(extension, route_config.get("path"))
|
||||
_wasm_extension_api_export(extension, route_config.get("export"))
|
||||
routes.append(
|
||||
{
|
||||
"method": method,
|
||||
@@ -217,16 +216,16 @@ def _match_wasm_extension_ui_route(
|
||||
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)
|
||||
for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
|
||||
route_path = _wasm_extension_ui_route_path(extension, route_config.get("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,
|
||||
"auth": _wasm_extension_route_auth(extension, route_config.get("auth")),
|
||||
"path_params": route_config.get("path_params") or {},
|
||||
"route_params": route_params,
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ 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.db import POSTGRES, SQLITE, Connection, Database
|
||||
from lnbits.settings import settings
|
||||
|
||||
_MIGRATION_FILE_RE = re.compile(r"^(\d+)_.*\.json$")
|
||||
@@ -76,16 +76,9 @@ async def storage_set_row(
|
||||
) -> 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}")
|
||||
columns = list(clean_data.keys())
|
||||
placeholders = [f":{column}" for column in columns]
|
||||
updates = [
|
||||
f"{column} = excluded.{column}"
|
||||
for column in columns
|
||||
@@ -94,19 +87,19 @@ async def storage_set_row(
|
||||
conflict_sql = (
|
||||
"DO UPDATE SET "
|
||||
+ ", ".join(updates)
|
||||
+ f" WHERE storage_row.{OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
|
||||
+ f" WHERE {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
|
||||
INSERT INTO {_table_ref_for_schema(ext_id, table)}
|
||||
({", ".join(columns)})
|
||||
VALUES
|
||||
({", ".join(placeholders)})
|
||||
ON CONFLICT (id) {conflict_sql}
|
||||
""" # noqa: S608
|
||||
|
||||
async with database.connect() as conn:
|
||||
async with Database(f"ext_{ext_id}").connect() as conn:
|
||||
await conn.execute(query, clean_data)
|
||||
|
||||
|
||||
@@ -124,10 +117,7 @@ async def storage_get_paginated_rows(
|
||||
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, values = _where_sql(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)
|
||||
@@ -147,7 +137,7 @@ async def storage_get_paginated_rows(
|
||||
{where_sql}
|
||||
""" # noqa: S608
|
||||
|
||||
async with database.connect() as conn:
|
||||
async with Database(f"ext_{ext_id}").connect() as conn:
|
||||
rows = await conn.fetchall(rows_query, values)
|
||||
count_row = await conn.fetchone(count_query, count_values)
|
||||
|
||||
@@ -413,25 +403,14 @@ def _filters_to_db(
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
]
|
||||
clauses = [f"{field} = :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 ""
|
||||
|
||||
@@ -5,8 +5,6 @@ from typing import Any
|
||||
|
||||
from wasmtime import Config, Engine
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .loader import WasmExtension
|
||||
|
||||
|
||||
@@ -14,37 +12,19 @@ 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:
|
||||
@lru_cache(maxsize=1)
|
||||
def _wasm_engine() -> 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:
|
||||
def _wasm_component(extension: WasmExtension) -> 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -53,10 +33,7 @@ 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
|
||||
)
|
||||
return component.Component.from_file(_wasm_engine(), module_path)
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
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
|
||||
@@ -1,56 +1,22 @@
|
||||
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),
|
||||
)
|
||||
if not extension_id:
|
||||
return
|
||||
|
||||
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)
|
||||
extension = core_app_extra.wasm_extension_registry.get(extension_id)
|
||||
if not extension:
|
||||
return
|
||||
|
||||
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
|
||||
@@ -68,12 +34,7 @@ async def _dispatch_wasm_invoice_paid_to_extension(
|
||||
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,
|
||||
owner_id=await _wasm_invoice_paid_owner_id(extension, payment),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -82,86 +43,6 @@ async def _dispatch_wasm_invoice_paid_to_extension(
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -173,7 +54,7 @@ def _payment_extension_id(payment: Any) -> str | 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)
|
||||
source_tables = _wasm_public_invoice_source_tables(extension.config)
|
||||
if not source_id or not source_tables:
|
||||
return None
|
||||
|
||||
@@ -190,31 +71,14 @@ def _payment_source_id(payment: Any) -> str | None:
|
||||
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]:
|
||||
def _wasm_public_invoice_source_tables(config: dict[str, Any]) -> list[str]:
|
||||
permissions = config.get("permissions") or []
|
||||
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":
|
||||
if not isinstance(permission, dict):
|
||||
continue
|
||||
policies = (
|
||||
permission.get("policies")
|
||||
if isinstance(permission, dict)
|
||||
else getattr(permission, "policies", None)
|
||||
)
|
||||
if permission.get("id") != "wallet.create_invoice_public":
|
||||
continue
|
||||
policies = permission.get("policies")
|
||||
if not isinstance(policies, list):
|
||||
return []
|
||||
return [
|
||||
@@ -227,14 +91,16 @@ def _wasm_public_invoice_source_tables_from_permissions(
|
||||
return []
|
||||
|
||||
|
||||
def _wasm_invoice_paid_export(config: Any) -> str | None:
|
||||
return config.events.on_invoice_paid
|
||||
def _wasm_invoice_paid_export(config: dict[str, Any]) -> str | None:
|
||||
events = config.get("events") or {}
|
||||
export_name = events.get("onInvoicePaid")
|
||||
return export_name if isinstance(export_name, str) and export_name else None
|
||||
|
||||
|
||||
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"
|
||||
if export.get("name") == export_name:
|
||||
return export.get("visibility") == "event"
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
@@ -17,9 +16,6 @@ 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,
|
||||
@@ -30,52 +26,9 @@ async def invoke_wasm_extension_export(
|
||||
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,
|
||||
)
|
||||
permissions = await _extension_permissions(extension)
|
||||
api = ExtensionHostAPI(
|
||||
extension.id,
|
||||
permissions,
|
||||
@@ -83,90 +36,17 @@ async def invoke_wasm_extension_export(
|
||||
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,
|
||||
)
|
||||
|
||||
return await asyncio.to_thread(
|
||||
_invoke_wasm_extension_export_sync,
|
||||
extension,
|
||||
export_name,
|
||||
payload or {},
|
||||
api,
|
||||
event_loop,
|
||||
)
|
||||
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(
|
||||
@@ -175,24 +55,17 @@ def _invoke_wasm_extension_export_sync(
|
||||
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"])
|
||||
engine = _wasm_engine()
|
||||
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)
|
||||
wasm_component = _wasm_component(extension)
|
||||
instance = linker.instantiate(store, wasm_component)
|
||||
function = instance.get_func(store, export_name)
|
||||
if not function:
|
||||
@@ -202,17 +75,21 @@ def _invoke_wasm_extension_export_sync(
|
||||
|
||||
result = function(store, json.dumps(payload))
|
||||
function.post_return(store)
|
||||
return _parse_wasm_export_result(result, limits)
|
||||
return _parse_wasm_export_result(extension, result)
|
||||
|
||||
|
||||
def _parse_wasm_export_result(value: Any, limits: dict[str, int]) -> dict[str, Any]:
|
||||
def _parse_wasm_export_result(extension: WasmExtension, value: Any) -> 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:
|
||||
max_response_bytes = (
|
||||
(extension.config.get("wasm") or {})
|
||||
.get("resource_limits", {})
|
||||
.get("max_response_bytes")
|
||||
)
|
||||
if isinstance(max_response_bytes, int):
|
||||
response_size = len(value.encode())
|
||||
if response_size > max_response_bytes:
|
||||
raise ValueError(
|
||||
@@ -232,48 +109,12 @@ def _get_registered_extension(ext_id: str) -> WasmExtension:
|
||||
raise RuntimeError(f"WASM extension '{ext_id}' is not registered.")
|
||||
|
||||
|
||||
async def _active_installed_extension(extension: WasmExtension) -> Any:
|
||||
async def _extension_permissions(extension: WasmExtension) -> list[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
|
||||
if not installed_extension:
|
||||
return []
|
||||
return installed_extension.permissions
|
||||
|
||||
|
||||
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())
|
||||
|
||||
@@ -5,11 +5,6 @@ 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
|
||||
|
||||
|
||||
@@ -22,13 +17,13 @@ class WasmExtension:
|
||||
module_path: Path
|
||||
wit_path: Path | None
|
||||
world: str
|
||||
exports: list[WasmExtensionExport]
|
||||
config: WasmExtensionConfig
|
||||
host_api: str
|
||||
exports: list[dict[str, Any]]
|
||||
config: dict[str, Any]
|
||||
|
||||
|
||||
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")
|
||||
config = load_wasm_extension_config(ext_id)
|
||||
return bool(config and config.get("extension_type") == "wasm")
|
||||
|
||||
|
||||
@@ -37,38 +32,36 @@ def is_wasm_extension_dir(ext_dir: Path) -> bool:
|
||||
return bool(config and config.get("extension_type") == "wasm")
|
||||
|
||||
|
||||
def load_wasm_extension_config(ext_id: str) -> WasmExtensionConfig | None:
|
||||
def load_wasm_extension_config(ext_id: str) -> dict[str, Any] | 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)
|
||||
return _load_json(ext_dir / "config.json")
|
||||
|
||||
|
||||
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:
|
||||
config = load_wasm_extension_config(ext_id)
|
||||
if not config:
|
||||
raise FileNotFoundError(f"Missing WASM extension config for '{ext_id}'.")
|
||||
if raw_config.get("extension_type") != "wasm":
|
||||
if 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)
|
||||
wasm_config = config.get("wasm") or {}
|
||||
module_path = _extension_path(ext_dir, wasm_config.get("module"))
|
||||
wit_path = _optional_extension_path(ext_dir, wasm_config.get("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,
|
||||
id=config.get("id") or ext_id,
|
||||
name=config.get("name") or ext_id,
|
||||
version=config.get("version") or "0.0",
|
||||
root_path=ext_dir,
|
||||
module_path=module_path,
|
||||
wit_path=wit_path,
|
||||
world=config.wasm.world,
|
||||
exports=config.wasm.exports,
|
||||
world=wasm_config.get("world") or "",
|
||||
host_api=wasm_config.get("host_api") or "lnbits.core.wasm_ext.ExtensionHostAPI",
|
||||
exports=wasm_config.get("exports") or [],
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
+9
-17
@@ -497,7 +497,7 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
||||
validated, errors = compare_field.validate(raw_value, {}, loc="none")
|
||||
if errors:
|
||||
raise ValidationError(errors=[errors], model=model)
|
||||
values[f"{field}__{i}_{index}"] = validated
|
||||
values[f"{field}__{index}"] = validated
|
||||
else:
|
||||
raise ValueError("Unknown filter field")
|
||||
|
||||
@@ -510,12 +510,11 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
||||
for key in self.values.keys() if self.values else []:
|
||||
if self.model and self.model.__fields__[self.field].type_ == datetime:
|
||||
placeholder = compat_timestamp_placeholder(key)
|
||||
else:
|
||||
placeholder = f":{key}"
|
||||
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
|
||||
stmt.append(placeholder)
|
||||
else:
|
||||
stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}")
|
||||
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
|
||||
stmt.append(f":{key}")
|
||||
else:
|
||||
stmt.append(f"{prefix}{self.field} {self.op.as_sql} :{key}")
|
||||
|
||||
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
|
||||
statement = f"{prefix}{self.field} {self.op.as_sql} ({', '.join(stmt)})"
|
||||
@@ -726,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 _is_subclass(type_, BaseModel) else v
|
||||
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
|
||||
for v in _items
|
||||
]
|
||||
continue
|
||||
if _is_subclass(type_, bool):
|
||||
if issubclass(type_, bool):
|
||||
_dict[key] = bool(value)
|
||||
continue
|
||||
if _is_subclass(type_, datetime):
|
||||
if issubclass(type_, datetime):
|
||||
if DB_TYPE == SQLITE:
|
||||
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
|
||||
else:
|
||||
_dict[key] = value.replace(tzinfo=timezone.utc)
|
||||
continue
|
||||
if _is_subclass(type_, BaseModel):
|
||||
if issubclass(type_, BaseModel):
|
||||
_dict[key] = dict_to_submodel(type_, value)
|
||||
continue
|
||||
# TODO: remove this when all sub models are migrated to Pydantic
|
||||
@@ -764,13 +763,6 @@ 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 (
|
||||
|
||||
@@ -372,13 +372,3 @@ def sha256s(value: str) -> str:
|
||||
Returns the hex as a string.
|
||||
"""
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def daystart_timestamp(dt: datetime | None = None) -> int:
|
||||
"""
|
||||
Returns the timestamp of the start of the day for the given
|
||||
datetime (or now in UTC if not provided).
|
||||
"""
|
||||
dt = dt or datetime.now(timezone.utc)
|
||||
day_start = dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return int(day_start.timestamp())
|
||||
|
||||
@@ -27,8 +27,6 @@ from lnbits.settings import set_cli_settings, settings
|
||||
@click.option(
|
||||
"--reload", is_flag=True, default=False, help="Enable auto-reload for development"
|
||||
)
|
||||
@click.option("--ws-max-queue", default=128, help="Websocket max queue size")
|
||||
@click.option("--ws-ping-timeout", default=60.0, help="Websocket ping timeout")
|
||||
def main(
|
||||
port: int,
|
||||
host: str,
|
||||
@@ -36,8 +34,6 @@ def main(
|
||||
ssl_keyfile: str,
|
||||
ssl_certfile: str,
|
||||
reload: bool,
|
||||
ws_max_queue: int,
|
||||
ws_ping_timeout: float,
|
||||
):
|
||||
"""Launched with `uv run lnbits` at root level"""
|
||||
|
||||
@@ -62,8 +58,6 @@ def main(
|
||||
ssl_keyfile=ssl_keyfile,
|
||||
ssl_certfile=ssl_certfile,
|
||||
reload=reload or False,
|
||||
ws_ping_timeout=ws_ping_timeout,
|
||||
ws_max_queue=ws_max_queue,
|
||||
)
|
||||
|
||||
server = uvicorn.Server(config=config)
|
||||
|
||||
@@ -60,7 +60,6 @@ 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="""
|
||||
@@ -82,33 +81,6 @@ 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
|
||||
@@ -524,11 +496,6 @@ class NotificationsSettings(LNbitsSettings):
|
||||
and self.lnbits_telegram_notifications_access_token is not None
|
||||
)
|
||||
|
||||
def is_email_notifications_configured(self) -> bool:
|
||||
return self.lnbits_email_notifications_enabled and bool(
|
||||
self.lnbits_email_notifications_email
|
||||
)
|
||||
|
||||
|
||||
class FakeWalletFundingSource(LNbitsSettings):
|
||||
fake_wallet_secret: str = Field(default="ToTheMoon1")
|
||||
@@ -1039,7 +1006,6 @@ class AuditSettings(LNbitsSettings):
|
||||
class EditableSettings(
|
||||
UsersSettings,
|
||||
ExtensionsSettings,
|
||||
WasmRuntimeLimits,
|
||||
ThemesSettings,
|
||||
OpsSettings,
|
||||
AssetSettings,
|
||||
@@ -1329,7 +1295,6 @@ class PublicSettings(BaseModel):
|
||||
extensions_reviews_url: str = Field(alias="extensionsReviewsUrl")
|
||||
ext_builder: bool = Field(alias="extBuilder")
|
||||
nostr_configured: bool = Field(alias="nostrConfigured")
|
||||
email_configured: bool = Field(alias="emailConfigured")
|
||||
telegram_configured: bool = Field(alias="telegramConfigured")
|
||||
wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel")
|
||||
wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl")
|
||||
@@ -1394,7 +1359,6 @@ class PublicSettings(BaseModel):
|
||||
extensionsReviewsUrl=settings.lnbits_extensions_reviews_url,
|
||||
extBuilder=settings.lnbits_extensions_builder_activate_non_admins,
|
||||
nostrConfigured=settings.is_nostr_notifications_configured(),
|
||||
emailConfigured=settings.is_email_notifications_configured(),
|
||||
telegramConfigured=settings.is_telegram_notifications_configured(),
|
||||
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
|
||||
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -116,7 +116,6 @@ window.localisation.en = {
|
||||
read: 'Read',
|
||||
write: 'Write',
|
||||
pay: 'Pay',
|
||||
sending: 'Sending',
|
||||
memo: 'Memo',
|
||||
date: 'Date',
|
||||
path: 'Path',
|
||||
@@ -521,14 +520,6 @@ window.localisation.en = {
|
||||
extension_cost: 'This release requires a payment of minimum {cost} sats.',
|
||||
extension_paid_sats: 'You have already paid {paid_sats} sats.',
|
||||
extension_permissions_title: 'Grant extension permissions',
|
||||
extension_permissions_tab: 'Extension Permissions',
|
||||
user_permissions_tab: 'My Grants',
|
||||
extension_permissions_none: 'This extension has no install-time permissions.',
|
||||
user_permissions_none:
|
||||
'You have not granted any permissions for this extension.',
|
||||
user_permissions_max_amount: 'Max payment amount',
|
||||
user_permissions_destination_policy: 'Allowed destinations',
|
||||
user_permissions_no_editable_settings: 'This grant has no editable settings.',
|
||||
extension_permissions_grant_install: 'Grant and install',
|
||||
extension_permissions_high_risk_warning:
|
||||
'This extension requests permissions that can move funds.',
|
||||
@@ -537,10 +528,6 @@ window.localisation.en = {
|
||||
extension_permission_risk_high: 'High risk',
|
||||
extension_permission_warning_wallet_pay_invoice:
|
||||
'Can spend funds from wallets available to your account.',
|
||||
extension_permission_warning_wallet_pay_invoice_background:
|
||||
'Can spend funds later from approved wallets without an active click.',
|
||||
extension_permission_warning_wallet_payments_watch:
|
||||
'Can read payment metadata for approved wallets.',
|
||||
extension_permission_warning_extension_api_request_write:
|
||||
'Can write data or trigger actions in approved extensions.',
|
||||
extension_permission_ext_storage_read: 'Read extension storage',
|
||||
@@ -555,15 +542,13 @@ window.localisation.en = {
|
||||
extension_permission_http_request_hosts: 'Allowed hosts',
|
||||
extension_permission_utils_basic: 'Use basic LNbits utilities',
|
||||
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
|
||||
extension_permission_wallet_payments_watch: 'Watch wallet payments',
|
||||
extension_permission_payments_watch: 'Watch payments',
|
||||
extension_permission_wallet_create_invoice: 'Create invoices',
|
||||
extension_permission_wallet_create_invoice_public:
|
||||
'Create Lightning invoices from public pages',
|
||||
extension_permission_wallet_balance_read: 'View wallet balances',
|
||||
extension_permission_wallet_list: 'List wallets',
|
||||
extension_permission_wallet_pay_invoice: 'Pay invoices',
|
||||
extension_permission_wallet_pay_invoice_background:
|
||||
'Make background payments',
|
||||
create_extension: 'Create Extension',
|
||||
release_details_error: 'Cannot get the release details.',
|
||||
pay_from_wallet: 'Pay from Wallet',
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
window.app.component('lnbits-admin-wasm-limit-config', {
|
||||
props: ['form-data'],
|
||||
template: '#lnbits-admin-wasm-limit-config',
|
||||
data() {
|
||||
return {
|
||||
selectedWasmExtensionId: null,
|
||||
wasmExtensionLimitDraft: {},
|
||||
wasmExtensionLimitsSaving: false,
|
||||
wasmRuntimeLimitExtensions: [],
|
||||
wasmRuntimeLimitExtensionsLoading: false,
|
||||
wasmLimitInfoDialog: {
|
||||
show: false,
|
||||
title: '',
|
||||
details: ''
|
||||
},
|
||||
wasmRuntimeLimitGroups: [
|
||||
{
|
||||
title: 'Execution',
|
||||
fields: [
|
||||
{
|
||||
name: 'wasm_runtime_max_execution_ms',
|
||||
label: 'Max execution time (ms)',
|
||||
description:
|
||||
'Maximum wall-clock time allowed for one WASM invocation.',
|
||||
details:
|
||||
'This is the elapsed time from starting the invocation until the export returns. When the limit is reached LNbits requests an interrupt and records the invocation as timed out if it cannot finish quickly. Use this to stop long sleeps, slow host calls, and CPU loops that run for too long.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_fuel',
|
||||
label: 'Max fuel',
|
||||
description:
|
||||
'Maximum Wasmtime instruction budget for one invocation.',
|
||||
details:
|
||||
'Fuel is Wasmtime instruction budgeting. It is more deterministic than wall-clock time for CPU-heavy loops because each executed instruction consumes budget. Set this low enough to stop busy loops, but high enough for legitimate extension startup and JSON processing.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_wasm_stack_bytes',
|
||||
label: 'Max WASM stack (bytes)',
|
||||
description: 'Maximum stack space for WASM calls and recursion.',
|
||||
details:
|
||||
'This limits stack used by WebAssembly function calls. It protects the server from deep recursion or very large call chains in extension code. If legitimate extensions fail with stack overflow traps, raise this carefully.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Memory and Data Size',
|
||||
fields: [
|
||||
{
|
||||
name: 'wasm_runtime_max_memory_bytes',
|
||||
label: 'Max memory (bytes)',
|
||||
description: 'Maximum WASM linear memory per invocation.',
|
||||
details:
|
||||
'This caps the linear memory visible to the WASM module. It limits memory.grow and can make instantiation fail if the module asks for too much memory up front. This does not include every byte used by the Python process or Wasmtime engine internals.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_request_bytes',
|
||||
label: 'Max request size (bytes)',
|
||||
description:
|
||||
'Maximum serialized input payload accepted before execution.',
|
||||
details:
|
||||
'This caps the serialized payload passed into a WASM export before execution starts. It protects against huge HTTP bodies, oversized event data, and expensive JSON parsing. Requests above this limit should be rejected before invoking the extension.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_response_bytes',
|
||||
label: 'Max response size (bytes)',
|
||||
description:
|
||||
'Maximum serialized response returned by a WASM export.',
|
||||
details:
|
||||
'This caps the JSON or string response returned by the WASM export. It prevents extensions from returning huge responses that consume memory, slow down API calls, or overload the browser. Responses above this limit are treated as invalid.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Wasmtime Objects',
|
||||
fields: [
|
||||
{
|
||||
name: 'wasm_runtime_max_table_elements',
|
||||
label: 'Max table elements',
|
||||
description: 'Maximum total elements allowed in WASM tables.',
|
||||
details:
|
||||
'Tables store references used by WebAssembly, commonly function references. Limiting table elements prevents a module from allocating very large reference tables. Each table element also has host memory overhead.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_instances',
|
||||
label: 'Max instances',
|
||||
description:
|
||||
'Maximum WebAssembly instances allowed inside one store.',
|
||||
details:
|
||||
'This limits how many WebAssembly instances can be created inside one Wasmtime store. LNbits normally needs one instance per invocation, so a low value is expected. Raising it should only be needed if the runtime starts supporting modules that instantiate other modules.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_tables',
|
||||
label: 'Max tables',
|
||||
description:
|
||||
'Maximum WebAssembly tables allowed inside one store.',
|
||||
details:
|
||||
'This limits the number of WebAssembly tables in the store. It is separate from table elements: one setting limits the number of tables, the other limits their total size. Keep this small unless a component model module legitimately needs more tables.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_memories',
|
||||
label: 'Max memories',
|
||||
description:
|
||||
'Maximum WebAssembly linear memories allowed inside one store.',
|
||||
details:
|
||||
'This limits how many separate linear memories a module can create. Most extensions should need only one memory. Keep this small to reduce memory accounting complexity and prevent multi-memory abuse.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Concurrency',
|
||||
fields: [
|
||||
{
|
||||
name: 'wasm_runtime_max_concurrent_invocations',
|
||||
label: 'Max concurrent invocations',
|
||||
description:
|
||||
'Maximum running WASM invocations across the server.',
|
||||
details:
|
||||
'This is the global cap for running WASM invocations across all extensions and users. It protects the LNbits process from thread exhaustion, CPU pressure, and too many simultaneous stores. New invocations should be rejected or queued once this is reached.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_concurrent_invocations_per_extension',
|
||||
label: 'Max concurrent per extension',
|
||||
description:
|
||||
'Maximum running WASM invocations for one extension.',
|
||||
details:
|
||||
'This caps how many invocations a single extension can run at once. It prevents one malicious or buggy extension from consuming the whole global concurrency budget. Set it lower than the global limit.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_concurrent_invocations_per_user',
|
||||
label: 'Max concurrent per user',
|
||||
description: 'Maximum running WASM invocations for one user.',
|
||||
details:
|
||||
'This caps concurrent invocations attributed to one user. It helps protect against a user repeatedly clicking, refreshing, or scripting extension calls. Invocations without a user can still be governed by the global and per-extension limits.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Host Calls',
|
||||
fields: [
|
||||
{
|
||||
name: 'wasm_runtime_max_host_calls',
|
||||
label: 'Max host calls',
|
||||
description:
|
||||
'Maximum total calls from WASM into LNbits host APIs.',
|
||||
details:
|
||||
'This is the total budget for calls from the WASM module into LNbits host APIs during one invocation. It should count all categories together. It limits chatty extensions and prevents tight loops that repeatedly call back into Python.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_http_calls',
|
||||
label: 'Max HTTP calls',
|
||||
description: 'Maximum outbound HTTP host calls per invocation.',
|
||||
details:
|
||||
'This caps outbound HTTP requests made through the host API during one invocation. It reduces SSRF blast radius, protects network resources, and limits slow external dependencies. It should be enforced together with HTTP timeout and response-size limits.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_storage_calls',
|
||||
label: 'Max storage calls',
|
||||
description: 'Maximum storage host calls per invocation.',
|
||||
details:
|
||||
'This caps extension storage operations during one invocation. It protects the database from excessive reads and writes triggered by malicious loops. Use it with storage payload-size limits if those are added later.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_wallet_calls',
|
||||
label: 'Max wallet calls',
|
||||
description: 'Maximum wallet/payment host calls per invocation.',
|
||||
details:
|
||||
'This caps wallet and payment-related host calls during one invocation. These calls are security-sensitive and may touch balances, invoices, or payments. Keep this conservative and rely on explicit permissions for what the extension is allowed to do.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'HTTP',
|
||||
fields: [
|
||||
{
|
||||
name: 'wasm_runtime_http_timeout_ms',
|
||||
label: 'HTTP timeout (ms)',
|
||||
description: 'Maximum time allowed for one WASM HTTP request.',
|
||||
details:
|
||||
'This is the per-request timeout for HTTP calls made through the WASM host API. It prevents a slow remote server from holding an invocation open indefinitely. The total invocation timeout still applies across all work.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_http_response_bytes',
|
||||
label: 'Max HTTP response size (bytes)',
|
||||
description:
|
||||
'Maximum response body size accepted from one WASM HTTP request.',
|
||||
details:
|
||||
'This caps the response body accepted from each HTTP call made by an extension. It protects memory and parsing time when a remote server returns a very large body. Responses above the limit should fail the host call.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
adminKey() {
|
||||
return this.g.user.wallets[0].adminkey
|
||||
},
|
||||
isExtensionLimitRoute() {
|
||||
return this.$route.path.startsWith('/admin/extensions/wasm/limits/')
|
||||
},
|
||||
routeWasmExtensionId() {
|
||||
return this.isExtensionLimitRoute ? this.$route.params.extId : null
|
||||
},
|
||||
backRoute() {
|
||||
return this.isExtensionLimitRoute
|
||||
? '/admin/extensions/wasm/limits'
|
||||
: '/admin#extensions'
|
||||
},
|
||||
backTooltip() {
|
||||
return this.isExtensionLimitRoute
|
||||
? 'Wasm Limit Config'
|
||||
: 'Extensions Settings'
|
||||
},
|
||||
pageDescription() {
|
||||
if (this.isExtensionLimitRoute) {
|
||||
return 'Customize limits for one installed WASM extension.'
|
||||
}
|
||||
return 'These values are global defaults. Use 0 to disable a global limit.'
|
||||
},
|
||||
wasmRuntimeLimitExtensionOptions() {
|
||||
return this.wasmRuntimeLimitExtensions.map(extension => ({
|
||||
label: `${extension.name || extension.id} (${extension.id})`,
|
||||
value: extension.id
|
||||
}))
|
||||
},
|
||||
selectedWasmRuntimeLimitExtension() {
|
||||
return (
|
||||
this.wasmRuntimeLimitExtensions.find(
|
||||
extension => extension.id === this.selectedWasmExtensionId
|
||||
) || null
|
||||
)
|
||||
},
|
||||
customWasmLimitCount() {
|
||||
const extension = this.selectedWasmRuntimeLimitExtension
|
||||
if (!extension || !extension.wasm_runtime_limits) {
|
||||
return 0
|
||||
}
|
||||
return Object.keys(extension.wasm_runtime_limits).length
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectedWasmExtensionId() {
|
||||
this.loadSelectedWasmRuntimeLimitExtension()
|
||||
},
|
||||
routeWasmExtensionId() {
|
||||
this.syncWasmExtensionLimitRoute()
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchWasmRuntimeLimitExtensions()
|
||||
},
|
||||
methods: {
|
||||
async fetchWasmRuntimeLimitExtensions() {
|
||||
this.wasmRuntimeLimitExtensionsLoading = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/api/v1/extension/wasm/runtime-limits/extensions',
|
||||
this.adminKey
|
||||
)
|
||||
this.wasmRuntimeLimitExtensions = data || []
|
||||
if (
|
||||
this.selectedWasmExtensionId &&
|
||||
!this.wasmRuntimeLimitExtensions.some(
|
||||
extension => extension.id === this.selectedWasmExtensionId
|
||||
)
|
||||
) {
|
||||
this.selectedWasmExtensionId = null
|
||||
}
|
||||
this.syncWasmExtensionLimitRoute()
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.wasmRuntimeLimitExtensionsLoading = false
|
||||
}
|
||||
},
|
||||
syncWasmExtensionLimitRoute() {
|
||||
this.selectedWasmExtensionId = this.routeWasmExtensionId
|
||||
this.loadSelectedWasmRuntimeLimitExtension()
|
||||
},
|
||||
openWasmExtensionLimit(extensionId) {
|
||||
this.$router.push(
|
||||
`/admin/extensions/wasm/limits/${encodeURIComponent(extensionId)}`
|
||||
)
|
||||
},
|
||||
loadSelectedWasmRuntimeLimitExtension() {
|
||||
const extension = this.selectedWasmRuntimeLimitExtension
|
||||
this.wasmExtensionLimitDraft = extension
|
||||
? {...(extension.wasm_runtime_limits || {})}
|
||||
: {}
|
||||
},
|
||||
wasmExtensionLimitHint(field) {
|
||||
const globalValue = this.formData[field.name]
|
||||
return `Inherited global value: ${globalValue}. ${field.description}`
|
||||
},
|
||||
wasmExtensionLimitPlaceholder(field) {
|
||||
const globalValue = this.formData[field.name]
|
||||
return globalValue === undefined || globalValue === null
|
||||
? ''
|
||||
: String(globalValue)
|
||||
},
|
||||
normalizedWasmExtensionLimitDraft() {
|
||||
const limits = {}
|
||||
this.wasmRuntimeLimitGroups.forEach(group => {
|
||||
group.fields.forEach(field => {
|
||||
const value = this.wasmExtensionLimitDraft[field.name]
|
||||
const cleanValue = typeof value === 'string' ? value.trim() : value
|
||||
if (
|
||||
cleanValue === '' ||
|
||||
cleanValue === null ||
|
||||
cleanValue === undefined
|
||||
) {
|
||||
return
|
||||
}
|
||||
const numericValue = Number(cleanValue)
|
||||
if (
|
||||
!Number.isFinite(numericValue) ||
|
||||
!Number.isInteger(numericValue) ||
|
||||
numericValue < 0
|
||||
) {
|
||||
throw new Error(`${field.label} must be a non-negative integer.`)
|
||||
}
|
||||
limits[field.name] = numericValue
|
||||
})
|
||||
})
|
||||
return limits
|
||||
},
|
||||
async clearWasmExtensionLimits() {
|
||||
this.wasmExtensionLimitDraft = {}
|
||||
await this.saveWasmExtensionLimits()
|
||||
},
|
||||
async saveWasmExtensionLimits() {
|
||||
if (!this.selectedWasmExtensionId) {
|
||||
return
|
||||
}
|
||||
this.wasmExtensionLimitsSaving = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/wasm/runtime-limits/${encodeURIComponent(
|
||||
this.selectedWasmExtensionId
|
||||
)}`,
|
||||
this.adminKey,
|
||||
{
|
||||
limits: this.normalizedWasmExtensionLimitDraft()
|
||||
}
|
||||
)
|
||||
const index = this.wasmRuntimeLimitExtensions.findIndex(
|
||||
extension => extension.id === data.id
|
||||
)
|
||||
if (index >= 0) {
|
||||
this.wasmRuntimeLimitExtensions.splice(index, 1, data)
|
||||
}
|
||||
this.loadSelectedWasmRuntimeLimitExtension()
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'WASM extension limits saved.'
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && !error.response) {
|
||||
Quasar.Notify.create({
|
||||
type: 'negative',
|
||||
message: error.message
|
||||
})
|
||||
} else {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
} finally {
|
||||
this.wasmExtensionLimitsSaving = false
|
||||
}
|
||||
},
|
||||
showWasmLimitInfo(field) {
|
||||
this.wasmLimitInfoDialog = {
|
||||
show: true,
|
||||
title: field.label,
|
||||
details: field.details
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,380 +0,0 @@
|
||||
window.app.component('lnbits-admin-wasm-runtime', {
|
||||
props: ['form-data'],
|
||||
template: '#lnbits-admin-wasm-runtime',
|
||||
data() {
|
||||
return {
|
||||
wasmRuntimeLoading: false,
|
||||
wasmHistoryLoading: false,
|
||||
wasmRuntimeTimer: null,
|
||||
wasmStats: {},
|
||||
wasmCurrentInvocations: [],
|
||||
wasmInvocationHistory: [],
|
||||
wasmStatItems: [
|
||||
{key: 'total', label: 'Total', icon: 'data_usage', color: 'primary'},
|
||||
{key: 'running', label: 'Running', icon: 'play_circle', color: 'green'},
|
||||
{key: 'completed', label: 'Completed', icon: 'task_alt', color: 'teal'},
|
||||
{key: 'failed', label: 'Failed', icon: 'error', color: 'red'},
|
||||
{
|
||||
key: 'stopped',
|
||||
label: 'Stopped',
|
||||
icon: 'stop_circle',
|
||||
color: 'orange'
|
||||
},
|
||||
{key: 'timeout', label: 'Timeouts', icon: 'timer_off', color: 'purple'}
|
||||
],
|
||||
wasmCurrentColumns: [
|
||||
{
|
||||
name: 'extension_id',
|
||||
label: 'Extension',
|
||||
field: 'extension_id',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'export_name',
|
||||
label: 'Export',
|
||||
field: 'export_name',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'trigger_type',
|
||||
label: 'Trigger',
|
||||
field: 'trigger_type',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
field: 'status',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'user_id',
|
||||
label: 'User',
|
||||
field: 'user_id',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'started_at',
|
||||
label: 'Started',
|
||||
field: 'started_at',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'duration_ms',
|
||||
label: 'Duration',
|
||||
field: row => row.duration_ms || 0,
|
||||
align: 'right',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'context',
|
||||
label: 'Context',
|
||||
field: row => this.wasmContextValue(row),
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{name: 'actions', label: '', field: 'actions', align: 'right'}
|
||||
],
|
||||
wasmHistoryColumns: [
|
||||
{
|
||||
name: 'extension_id',
|
||||
label: 'Extension',
|
||||
field: 'extension_id',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'export_name',
|
||||
label: 'Export',
|
||||
field: 'export_name',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'trigger_type',
|
||||
label: 'Trigger',
|
||||
field: 'trigger_type',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
field: 'status',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'user_id',
|
||||
label: 'User',
|
||||
field: 'user_id',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'started_at',
|
||||
label: 'Started',
|
||||
field: 'started_at',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'duration_ms',
|
||||
label: 'Duration',
|
||||
field: row => row.duration_ms || 0,
|
||||
align: 'right',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'calls',
|
||||
label: 'Calls',
|
||||
field: row => this.wasmCallCount(row),
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'context',
|
||||
label: 'Context',
|
||||
field: row => this.wasmContextValue(row),
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'error_message',
|
||||
label: 'Error/Stop Reason',
|
||||
field: row => row.error_message || row.stop_reason || '',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
adminKey() {
|
||||
return this.g.user.wallets[0].adminkey
|
||||
},
|
||||
wasmExtensionId() {
|
||||
return this.$route.params.extId || null
|
||||
},
|
||||
wasmExtensionQuery() {
|
||||
if (!this.wasmExtensionId) {
|
||||
return ''
|
||||
}
|
||||
return `extension_id=${encodeURIComponent(this.wasmExtensionId)}`
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
wasmExtensionId() {
|
||||
this.fetchWasmRuntime()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchWasmRuntime() {
|
||||
await Promise.all([
|
||||
this.fetchWasmCurrentInvocations(),
|
||||
this.fetchWasmInvocationHistory(),
|
||||
this.fetchWasmInvocationStats()
|
||||
])
|
||||
},
|
||||
async fetchWasmCurrentInvocations() {
|
||||
this.wasmRuntimeLoading = true
|
||||
try {
|
||||
const query = this.wasmExtensionQuery
|
||||
? `?${this.wasmExtensionQuery}`
|
||||
: ''
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/wasm/invocations/current${query}`,
|
||||
this.adminKey
|
||||
)
|
||||
this.wasmCurrentInvocations = data || []
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.wasmRuntimeLoading = false
|
||||
}
|
||||
},
|
||||
async fetchWasmInvocationHistory() {
|
||||
this.wasmHistoryLoading = true
|
||||
try {
|
||||
const params = ['limit=50']
|
||||
if (this.wasmExtensionQuery) {
|
||||
params.push(this.wasmExtensionQuery)
|
||||
}
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/wasm/invocations?${params.join('&')}`,
|
||||
this.adminKey
|
||||
)
|
||||
this.wasmInvocationHistory = data || []
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.wasmHistoryLoading = false
|
||||
}
|
||||
},
|
||||
async fetchWasmInvocationStats() {
|
||||
try {
|
||||
const params = ['hours=24']
|
||||
if (this.wasmExtensionQuery) {
|
||||
params.push(this.wasmExtensionQuery)
|
||||
}
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/wasm/invocations/stats?${params.join('&')}`,
|
||||
this.adminKey
|
||||
)
|
||||
this.wasmStats = data || {}
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
async stopWasmInvocation(invocationId) {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
`/api/v1/extension/wasm/invocations/${encodeURIComponent(invocationId)}/stop`,
|
||||
this.adminKey
|
||||
)
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'WASM invocation stop requested.'
|
||||
})
|
||||
await this.fetchWasmRuntime()
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
deactivateWasmExtension(extensionId) {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
`Deactivate extension '${extensionId}'?`,
|
||||
'Deactivate Extension'
|
||||
)
|
||||
.onOk(async () => {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/${encodeURIComponent(extensionId)}/deactivate`,
|
||||
this.adminKey
|
||||
)
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: `Extension '${extensionId}' deactivated.`
|
||||
})
|
||||
await this.fetchWasmRuntime()
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
})
|
||||
},
|
||||
formatWasmStat(key) {
|
||||
const value = this.wasmStats[key]
|
||||
return value === undefined || value === null ? '0' : String(value)
|
||||
},
|
||||
formatWasmDate(value) {
|
||||
return value ? this.utils.formatDate(value) : ''
|
||||
},
|
||||
wasmStatusColor(status) {
|
||||
return (
|
||||
{
|
||||
running: 'green',
|
||||
stopping: 'orange',
|
||||
completed: 'teal',
|
||||
failed: 'red',
|
||||
stopped: 'orange',
|
||||
timeout: 'purple',
|
||||
abandoned: 'grey'
|
||||
}[status] || 'grey'
|
||||
)
|
||||
},
|
||||
wasmTriggerColor(triggerType) {
|
||||
return (
|
||||
{
|
||||
http: 'primary',
|
||||
event: 'purple'
|
||||
}[triggerType] || 'grey'
|
||||
)
|
||||
},
|
||||
formatWasmDuration(row) {
|
||||
let duration = row.duration_ms
|
||||
if (
|
||||
(row.status === 'running' || row.status === 'stopping') &&
|
||||
row.started_at
|
||||
) {
|
||||
duration = Date.now() - new Date(row.started_at).getTime()
|
||||
}
|
||||
if (duration === undefined || duration === null) {
|
||||
return ''
|
||||
}
|
||||
if (duration >= 1000) {
|
||||
return `${(duration / 1000).toFixed(1)}s`
|
||||
}
|
||||
return `${duration}ms`
|
||||
},
|
||||
formatWasmCalls(row) {
|
||||
return [
|
||||
`host ${row.host_call_count || 0}`,
|
||||
`http ${row.http_call_count || 0}`,
|
||||
`storage ${row.storage_call_count || 0}`,
|
||||
`wallet ${row.wallet_call_count || 0}`
|
||||
].join(' / ')
|
||||
},
|
||||
wasmCallCount(row) {
|
||||
return (
|
||||
(row.host_call_count || 0) +
|
||||
(row.http_call_count || 0) +
|
||||
(row.storage_call_count || 0) +
|
||||
(row.wallet_call_count || 0)
|
||||
)
|
||||
},
|
||||
wasmContextValue(row) {
|
||||
return [
|
||||
row.method,
|
||||
row.path,
|
||||
row.event_type,
|
||||
row.wallet_id,
|
||||
row.payment_hash
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
},
|
||||
formatWasmContext(row) {
|
||||
const items = [
|
||||
row.method,
|
||||
row.path,
|
||||
row.event_type,
|
||||
row.wallet_id ? `wallet ${row.wallet_id}` : '',
|
||||
row.payment_hash ? `payment ${row.payment_hash.slice(0, 12)}...` : ''
|
||||
].filter(Boolean)
|
||||
return items.join(' | ')
|
||||
},
|
||||
formatWasmUserId(userId) {
|
||||
if (!userId) {
|
||||
return '-'
|
||||
}
|
||||
const value = String(userId)
|
||||
if (value.length <= 12) {
|
||||
return value
|
||||
}
|
||||
return `${value.slice(0, 3)}...${value.slice(-3)}`
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchWasmRuntime()
|
||||
this.wasmRuntimeTimer = setInterval(() => {
|
||||
this.fetchWasmCurrentInvocations()
|
||||
}, 5000)
|
||||
},
|
||||
unmounted() {
|
||||
if (this.wasmRuntimeTimer) {
|
||||
clearInterval(this.wasmRuntimeTimer)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -28,12 +28,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function mediumRisk(translateFn, warningKey) {
|
||||
function mediumRisk(translateFn) {
|
||||
return {
|
||||
level: 'medium',
|
||||
color: 'warning',
|
||||
label: translate(translateFn, 'extension_permission_risk_medium'),
|
||||
warning: warningKey ? translate(translateFn, warningKey) : ''
|
||||
warning: ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,16 +77,10 @@
|
||||
}
|
||||
|
||||
function permissionRiskForPermission(permission, extensions, translateFn) {
|
||||
if (
|
||||
['wallet.pay_invoice', 'wallet.pay_invoice_background'].includes(
|
||||
permission.id
|
||||
)
|
||||
) {
|
||||
if (permission.id === 'wallet.pay_invoice') {
|
||||
return highRisk(
|
||||
translateFn,
|
||||
permission.id === 'wallet.pay_invoice_background'
|
||||
? 'extension_permission_warning_wallet_pay_invoice_background'
|
||||
: 'extension_permission_warning_wallet_pay_invoice'
|
||||
'extension_permission_warning_wallet_pay_invoice'
|
||||
)
|
||||
}
|
||||
if (permission.id === 'extension.api.request') {
|
||||
@@ -104,18 +98,13 @@
|
||||
if (permission.id === 'http.request') {
|
||||
return mediumRisk(translateFn)
|
||||
}
|
||||
if (permission.id === 'wallet.payments.watch') {
|
||||
return mediumRisk(
|
||||
translateFn,
|
||||
'extension_permission_warning_wallet_payments_watch'
|
||||
)
|
||||
}
|
||||
if (
|
||||
[
|
||||
'wallet.list',
|
||||
'wallet.balance.read',
|
||||
'wallet.create_invoice_public',
|
||||
'ext.storage.read_public'
|
||||
'ext.storage.read_public',
|
||||
'payments.watch'
|
||||
].includes(permission.id)
|
||||
) {
|
||||
return mediumRisk(translateFn)
|
||||
@@ -135,8 +124,6 @@
|
||||
function permissionOrderIndex(permissionId) {
|
||||
const order = [
|
||||
'wallet.pay_invoice',
|
||||
'wallet.pay_invoice_background',
|
||||
'wallet.payments.watch',
|
||||
'wallet.list',
|
||||
'wallet.balance.read',
|
||||
'extension.api.request',
|
||||
|
||||
@@ -99,26 +99,6 @@ const routes = [
|
||||
name: 'Users',
|
||||
component: PageUsers
|
||||
},
|
||||
{
|
||||
path: '/admin/extensions/wasm',
|
||||
name: 'AdminWasmRuntime',
|
||||
component: PageAdmin
|
||||
},
|
||||
{
|
||||
path: '/admin/extensions/wasm/limits',
|
||||
name: 'AdminWasmLimitConfig',
|
||||
component: PageAdmin
|
||||
},
|
||||
{
|
||||
path: '/admin/extensions/wasm/limits/:extId',
|
||||
name: 'AdminWasmLimitConfigDetail',
|
||||
component: PageAdmin
|
||||
},
|
||||
{
|
||||
path: '/admin/extensions/wasm/:extId',
|
||||
name: 'AdminWasmRuntimeDetail',
|
||||
component: PageAdmin
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'Admin',
|
||||
|
||||
@@ -16,26 +16,18 @@ window.PageAdmin = {
|
||||
},
|
||||
watch: {
|
||||
tab(tab) {
|
||||
if (
|
||||
['wasm-runtime', 'wasm-limit-config'].includes(tab) &&
|
||||
this.$route.path.startsWith('/admin/extensions/wasm')
|
||||
) {
|
||||
return
|
||||
}
|
||||
const target = this.adminRouteForTab(tab)
|
||||
if (this.$route.fullPath !== target) {
|
||||
this.$router.push(target)
|
||||
}
|
||||
this.$router.push(`/admin#${tab}`)
|
||||
},
|
||||
$route(to) {
|
||||
const tab = this.adminTabFromRoute(to)
|
||||
if (this.tab !== tab) {
|
||||
this.tab = tab
|
||||
if (to.hash.length > 1) {
|
||||
this.tab = to.hash.replace('#', '')
|
||||
}
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
this.tab = this.adminTabFromRoute(this.$route)
|
||||
if (this.$route.hash.length > 1) {
|
||||
this.tab = this.$route.hash.replace('#', '')
|
||||
}
|
||||
await this.getSettings()
|
||||
},
|
||||
computed: {
|
||||
@@ -44,27 +36,6 @@ window.PageAdmin = {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
adminTabFromRoute(route) {
|
||||
if (route.path.startsWith('/admin/extensions/wasm/limits')) {
|
||||
return 'wasm-limit-config'
|
||||
}
|
||||
if (route.path.startsWith('/admin/extensions/wasm')) {
|
||||
return 'wasm-runtime'
|
||||
}
|
||||
if (route.hash.length > 1) {
|
||||
return route.hash.replace('#', '')
|
||||
}
|
||||
return 'funding'
|
||||
},
|
||||
adminRouteForTab(tab) {
|
||||
if (tab === 'wasm-runtime') {
|
||||
return '/admin/extensions/wasm'
|
||||
}
|
||||
if (tab === 'wasm-limit-config') {
|
||||
return '/admin/extensions/wasm/limits'
|
||||
}
|
||||
return `/admin#${tab}`
|
||||
},
|
||||
getDefaultSetting(fieldName) {
|
||||
LNbits.api.getDefaultSetting(fieldName).then(response => {
|
||||
this.formData[fieldName] = response.data.default_value
|
||||
|
||||
@@ -29,23 +29,6 @@ window.PageExtensions = {
|
||||
permissions: [],
|
||||
resolve: null
|
||||
},
|
||||
managedExtensionPermissions: {
|
||||
loading: false,
|
||||
extensionPermissions: [],
|
||||
userPermissions: {},
|
||||
savingKey: '',
|
||||
deletingKey: ''
|
||||
},
|
||||
backgroundPaymentDestinationOptions: [
|
||||
{
|
||||
label: 'Only transfers to my wallets',
|
||||
value: 'own_wallets_only'
|
||||
},
|
||||
{
|
||||
label: 'Allow external payments',
|
||||
value: 'external_allowed'
|
||||
}
|
||||
],
|
||||
uninstallAndDropDb: false,
|
||||
maxStars: 5,
|
||||
paylinkWebsocket: null,
|
||||
@@ -115,32 +98,6 @@ window.PageExtensions = {
|
||||
this.filterExtensions(this.searchTerm, val)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
managedUserPermissionRows() {
|
||||
const rows = []
|
||||
const userPermissions =
|
||||
this.managedExtensionPermissions.userPermissions || {}
|
||||
Object.entries(userPermissions).forEach(([permissionId, grants]) => {
|
||||
if (!Array.isArray(grants)) return
|
||||
grants.forEach(grant => {
|
||||
if (!grant || typeof grant !== 'object') return
|
||||
const grantId = String(grant.id || '')
|
||||
const walletId = String(grant.wallet_id || '')
|
||||
if (!grantId || !walletId) return
|
||||
rows.push({
|
||||
key: grantId,
|
||||
permissionId,
|
||||
label: this.permissionLabelById(permissionId),
|
||||
grantId,
|
||||
walletId,
|
||||
walletName: this.walletName(walletId),
|
||||
grant
|
||||
})
|
||||
})
|
||||
})
|
||||
return rows
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
filterExtensions(term, tab) {
|
||||
// Filter the extensions list
|
||||
@@ -207,11 +164,6 @@ window.PageExtensions = {
|
||||
)
|
||||
extension.isAvailable = true
|
||||
extension.isInstalled = true
|
||||
extension.isWasm =
|
||||
response.data.is_wasm === true ||
|
||||
response.data.isWasm === true ||
|
||||
release.extension_type === 'wasm' ||
|
||||
extension.isWasm === true
|
||||
extension.icon = response.data.icon || extension.icon
|
||||
extension.installedRelease = release
|
||||
this.toggleExtension(extension)
|
||||
@@ -400,18 +352,8 @@ window.PageExtensions = {
|
||||
this.selectedExtension = extension
|
||||
this.selectedRelease = null
|
||||
this.selectedExtensionRepos = null
|
||||
this.resetManagedExtensionPermissions()
|
||||
this.manageExtensionTab = this.g.user.admin
|
||||
? 'releases'
|
||||
: 'extension-permissions'
|
||||
this.manageExtensionTab = 'releases'
|
||||
this.showManageExtensionDialog = true
|
||||
if (this.canManageExtensionPermissions(extension)) {
|
||||
this.loadManagedExtensionPermissions(extension)
|
||||
}
|
||||
|
||||
if (!this.g.user.admin) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
@@ -449,61 +391,6 @@ window.PageExtensions = {
|
||||
extension.inProgress = false
|
||||
}
|
||||
},
|
||||
canShowManageExtensionButton(extension) {
|
||||
return (
|
||||
this.g.user.admin ||
|
||||
(extension?.isWasm === true && extension?.isInstalled === true)
|
||||
)
|
||||
},
|
||||
canManageExtensionPermissions(extension = this.selectedExtension) {
|
||||
return extension?.isWasm === true && extension?.isInstalled === true
|
||||
},
|
||||
canShowAdminManageTabs() {
|
||||
return this.g.user.admin === true
|
||||
},
|
||||
resetManagedExtensionPermissions() {
|
||||
this.managedExtensionPermissions = {
|
||||
loading: false,
|
||||
extensionPermissions: [],
|
||||
userPermissions: {},
|
||||
savingKey: '',
|
||||
deletingKey: ''
|
||||
}
|
||||
},
|
||||
async loadManagedExtensionPermissions(extension = this.selectedExtension) {
|
||||
if (!this.canManageExtensionPermissions(extension)) return
|
||||
this.managedExtensionPermissions.loading = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/${extension.id}/permissions`
|
||||
)
|
||||
this.managedExtensionPermissions.extensionPermissions =
|
||||
data.extension_permissions || []
|
||||
this.managedExtensionPermissions.userPermissions =
|
||||
this.cloneUserPermissions(data.user_permissions || {})
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.managedExtensionPermissions.loading = false
|
||||
}
|
||||
},
|
||||
cloneUserPermissions(userPermissions) {
|
||||
const permissions = {}
|
||||
Object.entries(userPermissions || {}).forEach(
|
||||
([permissionId, grants]) => {
|
||||
if (!Array.isArray(grants)) return
|
||||
permissions[permissionId] = grants
|
||||
.filter(grant => grant && typeof grant === 'object')
|
||||
.map(grant => ({
|
||||
...grant,
|
||||
_original: {...grant}
|
||||
}))
|
||||
}
|
||||
)
|
||||
return permissions
|
||||
},
|
||||
|
||||
async showExtensionDetails(extId, detailsLink) {
|
||||
if (!detailsLink) {
|
||||
@@ -750,135 +637,6 @@ window.PageExtensions = {
|
||||
extensionOpenUrl(extension) {
|
||||
return extension.isWasm ? `/ext/${extension.id}` : `/${extension.id}`
|
||||
},
|
||||
permissionLabelById(permissionId) {
|
||||
const key = `extension_permission_${String(permissionId).replace(
|
||||
/[^A-Za-z0-9]/g,
|
||||
'_'
|
||||
)}`
|
||||
const label = this.$t(key)
|
||||
return label === key ? permissionId : label
|
||||
},
|
||||
walletName(walletId) {
|
||||
const wallet = (this.g.user.wallets || []).find(
|
||||
wallet => wallet.id === walletId
|
||||
)
|
||||
return wallet ? wallet.name || wallet.id : walletId
|
||||
},
|
||||
userPermissionRowCaption(row) {
|
||||
return `${row.walletName} (${row.walletId.slice(0, 8)}...)`
|
||||
},
|
||||
isBackgroundPaymentPermission(row) {
|
||||
return row.permissionId === 'wallet.pay_invoice_background'
|
||||
},
|
||||
userPermissionGrantPayload(row) {
|
||||
return {
|
||||
wallet_id: row.walletId,
|
||||
max_amount: this.positiveInteger(row.grant.max_amount, 0),
|
||||
destination_policy: this.backgroundPaymentDestinationPolicy(
|
||||
row.grant.destination_policy
|
||||
)
|
||||
}
|
||||
},
|
||||
positiveInteger(value, fallback) {
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number) || number <= 0) return fallback
|
||||
return Math.floor(number)
|
||||
},
|
||||
backgroundPaymentDestinationPolicy(value) {
|
||||
return value === 'external_allowed'
|
||||
? 'external_allowed'
|
||||
: 'own_wallets_only'
|
||||
},
|
||||
backgroundPaymentGrantIncreased(row, payload) {
|
||||
const original = row.grant._original || {}
|
||||
const originalAmount = this.positiveInteger(original.max_amount, 0)
|
||||
const originalPolicy = this.backgroundPaymentDestinationPolicy(
|
||||
original.destination_policy
|
||||
)
|
||||
return (
|
||||
payload.max_amount > originalAmount ||
|
||||
(originalPolicy === 'own_wallets_only' &&
|
||||
payload.destination_policy === 'external_allowed')
|
||||
)
|
||||
},
|
||||
confirmUserPermissionIncrease() {
|
||||
return new Promise(resolve => {
|
||||
let resolved = false
|
||||
const finish = value => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
resolve(value)
|
||||
}
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'This increases what the extension can do with this wallet. Continue?'
|
||||
)
|
||||
.onOk(() => finish(true))
|
||||
.onCancel(() => finish(false))
|
||||
.onDismiss(() => finish(false))
|
||||
})
|
||||
},
|
||||
async saveUserPermissionGrant(row) {
|
||||
if (!this.isBackgroundPaymentPermission(row)) return
|
||||
const payload = this.userPermissionGrantPayload(row)
|
||||
if (!payload.max_amount) {
|
||||
Quasar.Notify.create({
|
||||
type: 'negative',
|
||||
message: 'Max payment amount must be greater than zero.'
|
||||
})
|
||||
return
|
||||
}
|
||||
if (
|
||||
this.backgroundPaymentGrantIncreased(row, payload) &&
|
||||
!(await this.confirmUserPermissionIncrease())
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.managedExtensionPermissions.savingKey = row.key
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
`/api/v1/extension/${this.selectedExtension.id}/permissions/background-payment`,
|
||||
null,
|
||||
payload
|
||||
)
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Permission updated.'
|
||||
})
|
||||
await this.loadManagedExtensionPermissions()
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.managedExtensionPermissions.savingKey = ''
|
||||
}
|
||||
},
|
||||
deleteUserPermissionGrant(row) {
|
||||
LNbits.utils
|
||||
.confirmDialog('Remove this permission grant?')
|
||||
.onOk(async () => {
|
||||
this.managedExtensionPermissions.deletingKey = row.key
|
||||
try {
|
||||
const grantId = encodeURIComponent(row.grantId)
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
`/api/v1/extension/${this.selectedExtension.id}/permissions/user/${grantId}`
|
||||
)
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Permission removed.'
|
||||
})
|
||||
await this.loadManagedExtensionPermissions()
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.managedExtensionPermissions.deletingKey = ''
|
||||
}
|
||||
})
|
||||
},
|
||||
async getGitHubReleaseDetails(release) {
|
||||
if (!release.is_github_release || release.loaded) {
|
||||
return
|
||||
|
||||
@@ -7,7 +7,6 @@ window.PageWallet = {
|
||||
invoice: null,
|
||||
lnurlpay: null,
|
||||
lnurlauth: null,
|
||||
sending: false,
|
||||
data: {
|
||||
request: '',
|
||||
amount: 0,
|
||||
@@ -132,7 +131,6 @@ window.PageWallet = {
|
||||
this.parse.data.request = ''
|
||||
this.parse.data.comment = ''
|
||||
this.parse.data.internalMemo = null
|
||||
this.parse.sending = false
|
||||
this.parse.data.paymentChecker = null
|
||||
this.parse.camera.show = false
|
||||
},
|
||||
@@ -364,9 +362,6 @@ window.PageWallet = {
|
||||
this.parse.invoice = Object.freeze(cleanInvoice)
|
||||
},
|
||||
payInvoice() {
|
||||
if (this.parse.sending) return
|
||||
|
||||
this.parse.sending = true
|
||||
const dismissPaymentMsg = Quasar.Notify.create({
|
||||
timeout: 0,
|
||||
message: this.$t('payment_processing')
|
||||
@@ -379,7 +374,6 @@ window.PageWallet = {
|
||||
this.parse.data.internalMemo
|
||||
)
|
||||
.then(response => {
|
||||
this.parse.sending = false
|
||||
dismissPaymentMsg()
|
||||
this.g.updatePayments = !this.g.updatePayments
|
||||
this.parse.show = false
|
||||
@@ -397,16 +391,13 @@ window.PageWallet = {
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
this.parse.sending = false
|
||||
dismissPaymentMsg()
|
||||
LNbits.utils.notifyApiError(err)
|
||||
this.g.updatePayments = !this.g.updatePayments
|
||||
this.parse.show = false
|
||||
})
|
||||
},
|
||||
payLnurl() {
|
||||
if (this.parse.sending) return
|
||||
|
||||
this.parse.sending = true
|
||||
LNbits.api
|
||||
.request('post', '/api/v1/payments/lnurl', this.g.wallet.adminkey, {
|
||||
res: this.parse.lnurlpay,
|
||||
@@ -417,7 +408,6 @@ window.PageWallet = {
|
||||
internalMemo: this.parse.data.internalMemo
|
||||
})
|
||||
.then(response => {
|
||||
this.parse.sending = false
|
||||
this.parse.show = false
|
||||
if (response.data.extra.success_action) {
|
||||
const action = JSON.parse(response.data.extra.success_action)
|
||||
@@ -474,10 +464,7 @@ window.PageWallet = {
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
this.parse.sending = false
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
},
|
||||
authLnurl() {
|
||||
const dismissAuthMsg = Quasar.Notify.create({
|
||||
|
||||
@@ -48,85 +48,6 @@ window.WasmExtensionComponent = {
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-dialog v-model="backgroundPaymentPrompt.show" persistent>
|
||||
<q-card style="width: min(560px, calc(100vw - 32px)); max-width: 560px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Background payments</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none q-gutter-md">
|
||||
<div>
|
||||
{{ backgroundPaymentPrompt.extensionName }} wants permission to make
|
||||
background payments from
|
||||
<strong>{{ backgroundPaymentPrompt.walletName }}</strong>.
|
||||
</div>
|
||||
<q-banner dense rounded class="bg-warning text-dark">
|
||||
This permission can move funds later without an active click.
|
||||
</q-banner>
|
||||
<q-input
|
||||
v-model.number="backgroundPaymentPrompt.form.maxAmount"
|
||||
type="number"
|
||||
label="Max payment amount (sats)"
|
||||
min="1"
|
||||
dense
|
||||
outlined
|
||||
></q-input>
|
||||
<q-select
|
||||
v-model="backgroundPaymentPrompt.form.destinationPolicy"
|
||||
:options="backgroundPaymentDestinationOptions"
|
||||
emit-value
|
||||
map-options
|
||||
label="Allowed destinations"
|
||||
dense
|
||||
outlined
|
||||
></q-select>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn
|
||||
flat
|
||||
color="negative"
|
||||
label="Deny"
|
||||
@click="resolveBackgroundPaymentPrompt(false)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
label="Allow"
|
||||
@click="resolveBackgroundPaymentPrompt(true)"
|
||||
></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-dialog v-model="walletPaymentWatchPrompt.show" persistent>
|
||||
<q-card style="width: min(520px, calc(100vw - 32px)); max-width: 520px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Watch wallet payments</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none q-gutter-md">
|
||||
<div>
|
||||
{{ walletPaymentWatchPrompt.extensionName }} wants permission to receive
|
||||
payment notifications for
|
||||
<strong>{{ walletPaymentWatchPrompt.walletName }}</strong>.
|
||||
</div>
|
||||
<q-banner dense rounded class="bg-warning text-dark">
|
||||
This permission exposes payment metadata for this wallet to the extension.
|
||||
</q-banner>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn
|
||||
flat
|
||||
color="negative"
|
||||
label="Deny"
|
||||
@click="resolveWalletPaymentWatchPrompt(false)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
label="Allow"
|
||||
@click="resolveWalletPaymentWatchPrompt(true)"
|
||||
></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
`,
|
||||
data() {
|
||||
@@ -147,36 +68,6 @@ window.WasmExtensionComponent = {
|
||||
resolve: null,
|
||||
show: false
|
||||
},
|
||||
backgroundPaymentDestinationOptions: [
|
||||
{
|
||||
label: 'Only transfers to my wallets',
|
||||
value: 'own_wallets_only'
|
||||
},
|
||||
{
|
||||
label: 'Allow external payments',
|
||||
value: 'external_allowed'
|
||||
}
|
||||
],
|
||||
backgroundPaymentPrompt: {
|
||||
extensionName: '',
|
||||
form: {
|
||||
destinationPolicy: 'own_wallets_only',
|
||||
maxAmount: 0
|
||||
},
|
||||
reject: null,
|
||||
resolve: null,
|
||||
show: false,
|
||||
walletId: '',
|
||||
walletName: ''
|
||||
},
|
||||
walletPaymentWatchPrompt: {
|
||||
extensionName: '',
|
||||
reject: null,
|
||||
resolve: null,
|
||||
show: false,
|
||||
walletId: '',
|
||||
walletName: ''
|
||||
},
|
||||
error: '',
|
||||
extensionName: '',
|
||||
frameUrl: '',
|
||||
@@ -193,12 +84,6 @@ window.WasmExtensionComponent = {
|
||||
unmounted() {
|
||||
window.removeEventListener('message', this.handleWindowMessage)
|
||||
this.rejectCameraPrompt('Camera scan cancelled.')
|
||||
this.rejectBackgroundPaymentPrompt(
|
||||
'Background payment permission cancelled.'
|
||||
)
|
||||
this.rejectWalletPaymentWatchPrompt(
|
||||
'Wallet payment watch permission cancelled.'
|
||||
)
|
||||
this.closeBridgePort()
|
||||
},
|
||||
watch: {
|
||||
@@ -234,20 +119,6 @@ window.WasmExtensionComponent = {
|
||||
cameraPromptStorageKey() {
|
||||
return `lnbits.ext.permissions.${this.bridge.extensionId}.ui.camera.scan_qr`
|
||||
},
|
||||
emptyBackgroundPaymentPrompt() {
|
||||
return {
|
||||
extensionName: '',
|
||||
form: {
|
||||
destinationPolicy: 'own_wallets_only',
|
||||
maxAmount: 0
|
||||
},
|
||||
reject: null,
|
||||
resolve: null,
|
||||
show: false,
|
||||
walletId: '',
|
||||
walletName: ''
|
||||
}
|
||||
},
|
||||
emptyCameraPrompt() {
|
||||
return {
|
||||
extensionName: '',
|
||||
@@ -256,16 +127,6 @@ window.WasmExtensionComponent = {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
emptyWalletPaymentWatchPrompt() {
|
||||
return {
|
||||
extensionName: '',
|
||||
reject: null,
|
||||
resolve: null,
|
||||
show: false,
|
||||
walletId: '',
|
||||
walletName: ''
|
||||
}
|
||||
},
|
||||
plainValue(value) {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
@@ -282,12 +143,6 @@ window.WasmExtensionComponent = {
|
||||
this.bridge = this.emptyBridge()
|
||||
this.allowedPaymentHashes.clear()
|
||||
this.rejectCameraPrompt('Camera scan cancelled.')
|
||||
this.rejectBackgroundPaymentPrompt(
|
||||
'Background payment permission cancelled.'
|
||||
)
|
||||
this.rejectWalletPaymentWatchPrompt(
|
||||
'Wallet payment watch permission cancelled.'
|
||||
)
|
||||
this.closeBridgePort()
|
||||
|
||||
try {
|
||||
@@ -455,320 +310,6 @@ window.WasmExtensionComponent = {
|
||||
this.g.scanner = onScan
|
||||
})
|
||||
},
|
||||
async requestBackgroundPaymentPermission(message) {
|
||||
const response = await this.requestExtensionPermissions({
|
||||
permissions: [
|
||||
{
|
||||
id: 'wallet.pay_invoice_background',
|
||||
grant: message?.grant || {}
|
||||
}
|
||||
]
|
||||
})
|
||||
return response.permissions?.[0] || response
|
||||
},
|
||||
async requestWalletPaymentWatchPermission(message) {
|
||||
const response = await this.requestExtensionPermissions({
|
||||
permissions: [
|
||||
{
|
||||
id: 'wallet.payments.watch',
|
||||
grant: message?.grant || {}
|
||||
}
|
||||
]
|
||||
})
|
||||
return response.permissions?.[0] || response
|
||||
},
|
||||
async requestExtensionPermissions(message) {
|
||||
if (this.bridge.public) {
|
||||
throw new Error('Public pages cannot request permissions.')
|
||||
}
|
||||
const permissions = Array.isArray(message.permissions)
|
||||
? message.permissions
|
||||
: []
|
||||
if (!permissions.length) {
|
||||
throw new Error('No permissions requested.')
|
||||
}
|
||||
|
||||
const requestedPermissions = permissions.map(permission =>
|
||||
this.normalizePermissionRequest(permission)
|
||||
)
|
||||
const checkResult = await this.checkExtensionPermissions(
|
||||
requestedPermissions.map(permission => ({
|
||||
id: permission.id,
|
||||
grant: permission.grant
|
||||
}))
|
||||
)
|
||||
const checks = Array.isArray(checkResult?.permissions)
|
||||
? checkResult.permissions
|
||||
: []
|
||||
const approvedLabels = []
|
||||
const results = []
|
||||
|
||||
for (const [index, permission] of requestedPermissions.entries()) {
|
||||
const check = checks[index] || {}
|
||||
if (check.id && check.id !== permission.id) {
|
||||
throw new Error('Permission check response did not match request.')
|
||||
}
|
||||
|
||||
if (check.approved) {
|
||||
approvedLabels.push(permission.label)
|
||||
results.push({
|
||||
id: permission.id,
|
||||
approved: true,
|
||||
grant: check.grant || permission.grant
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const granted = await this.promptExtensionPermission(permission)
|
||||
results.push({
|
||||
id: permission.id,
|
||||
approved: true,
|
||||
grant: granted?.grant || permission.grant
|
||||
})
|
||||
}
|
||||
|
||||
this.notifyApprovedPermissionUse(approvedLabels)
|
||||
return {permissions: results}
|
||||
},
|
||||
normalizePermissionRequest(permission) {
|
||||
const id = String(permission?.id || '')
|
||||
const grant = permission?.grant || {}
|
||||
if (id === 'wallet.pay_invoice_background') {
|
||||
return this.normalizeBackgroundPaymentPermission(grant)
|
||||
}
|
||||
if (id === 'wallet.payments.watch') {
|
||||
return this.normalizeWalletPaymentWatchPermission(grant)
|
||||
}
|
||||
throw new Error(`Unsupported permission request: ${id}.`)
|
||||
},
|
||||
normalizeBackgroundPaymentPermission(grant) {
|
||||
if (!this.hasBridgePermission('wallet.pay_invoice_background')) {
|
||||
throw new Error('Extension is missing background payment permission.')
|
||||
}
|
||||
|
||||
const walletId = String(grant.walletId || grant.wallet_id || '')
|
||||
const wallet = this.walletById(walletId)
|
||||
if (!wallet) {
|
||||
throw new Error('Selected wallet is not available.')
|
||||
}
|
||||
if (wallet.walletType === 'lightning-shared') {
|
||||
throw new Error(
|
||||
'Background payments are not allowed from shared wallets.'
|
||||
)
|
||||
}
|
||||
|
||||
const requestedGrant = {
|
||||
wallet_id: walletId,
|
||||
max_amount: this.positiveInteger(
|
||||
grant.maxAmount || grant.max_amount,
|
||||
1000
|
||||
),
|
||||
destination_policy: this.backgroundPaymentDestinationPolicy(
|
||||
grant.destinationPolicy || grant.destination_policy
|
||||
)
|
||||
}
|
||||
if (!requestedGrant.max_amount) {
|
||||
throw new Error('Max payment amount must be greater than zero.')
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'wallet.pay_invoice_background',
|
||||
grant: requestedGrant,
|
||||
label: `Background payments from ${wallet.name || walletId}`,
|
||||
wallet
|
||||
}
|
||||
},
|
||||
normalizeWalletPaymentWatchPermission(grant) {
|
||||
if (!this.hasBridgePermission('wallet.payments.watch')) {
|
||||
throw new Error('Extension is missing wallet payment watch permission.')
|
||||
}
|
||||
|
||||
const walletId = String(grant.walletId || grant.wallet_id || '')
|
||||
const wallet = this.walletById(walletId)
|
||||
if (!wallet) {
|
||||
throw new Error('Selected wallet is not available.')
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'wallet.payments.watch',
|
||||
grant: {wallet_id: walletId},
|
||||
label: `Watch wallet payments for ${wallet.name || walletId}`,
|
||||
wallet
|
||||
}
|
||||
},
|
||||
walletById(walletId) {
|
||||
return (
|
||||
(this.g?.user?.wallets || []).find(wallet => wallet.id === walletId) ||
|
||||
null
|
||||
)
|
||||
},
|
||||
async checkExtensionPermissions(permissions) {
|
||||
return await this.postExtensionPermission(
|
||||
'check',
|
||||
{permissions},
|
||||
'Could not check extension permissions.'
|
||||
)
|
||||
},
|
||||
promptExtensionPermission(permission) {
|
||||
if (permission.id === 'wallet.pay_invoice_background') {
|
||||
return this.promptBackgroundPaymentPermission(permission)
|
||||
}
|
||||
if (permission.id === 'wallet.payments.watch') {
|
||||
return this.promptWalletPaymentWatchPermission(permission)
|
||||
}
|
||||
throw new Error(`Unsupported permission request: ${permission.id}.`)
|
||||
},
|
||||
promptBackgroundPaymentPermission(permission) {
|
||||
if (this.backgroundPaymentPrompt.show) {
|
||||
throw new Error('Background payment prompt is already open.')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.backgroundPaymentPrompt = {
|
||||
extensionName:
|
||||
this.extensionName || this.bridge.extensionId || 'This extension',
|
||||
form: {
|
||||
destinationPolicy: permission.grant.destination_policy,
|
||||
maxAmount: permission.grant.max_amount
|
||||
},
|
||||
reject,
|
||||
resolve,
|
||||
show: true,
|
||||
walletId: permission.grant.wallet_id,
|
||||
walletName: permission.wallet.name || permission.grant.wallet_id
|
||||
}
|
||||
})
|
||||
},
|
||||
async resolveBackgroundPaymentPrompt(approved) {
|
||||
const prompt = this.backgroundPaymentPrompt
|
||||
if (!prompt.show) return
|
||||
|
||||
if (!approved) {
|
||||
this.backgroundPaymentPrompt = this.emptyBackgroundPaymentPrompt()
|
||||
prompt.reject?.(new Error('Background payment permission denied.'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const grant = {
|
||||
wallet_id: prompt.walletId,
|
||||
max_amount: this.positiveInteger(prompt.form.maxAmount, 0),
|
||||
destination_policy: this.backgroundPaymentDestinationPolicy(
|
||||
prompt.form.destinationPolicy
|
||||
)
|
||||
}
|
||||
if (!grant.max_amount) {
|
||||
throw new Error('Max payment amount must be greater than zero.')
|
||||
}
|
||||
|
||||
const data = await this.postExtensionPermission(
|
||||
'background-payment',
|
||||
grant,
|
||||
'Could not save permission.'
|
||||
)
|
||||
|
||||
this.backgroundPaymentPrompt = this.emptyBackgroundPaymentPrompt()
|
||||
prompt.resolve?.(data)
|
||||
} catch (error) {
|
||||
prompt.reject?.(error)
|
||||
this.backgroundPaymentPrompt = this.emptyBackgroundPaymentPrompt()
|
||||
}
|
||||
},
|
||||
rejectBackgroundPaymentPrompt(message) {
|
||||
const reject = this.backgroundPaymentPrompt.reject
|
||||
this.backgroundPaymentPrompt = this.emptyBackgroundPaymentPrompt()
|
||||
reject?.(new Error(message))
|
||||
},
|
||||
promptWalletPaymentWatchPermission(permission) {
|
||||
if (this.walletPaymentWatchPrompt.show) {
|
||||
throw new Error('Wallet payment watch prompt is already open.')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.walletPaymentWatchPrompt = {
|
||||
extensionName:
|
||||
this.extensionName || this.bridge.extensionId || 'This extension',
|
||||
reject,
|
||||
resolve,
|
||||
show: true,
|
||||
walletId: permission.grant.wallet_id,
|
||||
walletName: permission.wallet.name || permission.grant.wallet_id
|
||||
}
|
||||
})
|
||||
},
|
||||
async resolveWalletPaymentWatchPrompt(approved) {
|
||||
const prompt = this.walletPaymentWatchPrompt
|
||||
if (!prompt.show) return
|
||||
|
||||
if (!approved) {
|
||||
this.walletPaymentWatchPrompt = this.emptyWalletPaymentWatchPrompt()
|
||||
prompt.reject?.(new Error('Wallet payment watch permission denied.'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.postExtensionPermission(
|
||||
'wallet-payments-watch',
|
||||
{wallet_id: prompt.walletId},
|
||||
'Could not save permission.'
|
||||
)
|
||||
|
||||
this.walletPaymentWatchPrompt = this.emptyWalletPaymentWatchPrompt()
|
||||
prompt.resolve?.(data)
|
||||
} catch (error) {
|
||||
prompt.reject?.(error)
|
||||
this.walletPaymentWatchPrompt = this.emptyWalletPaymentWatchPrompt()
|
||||
}
|
||||
},
|
||||
rejectWalletPaymentWatchPrompt(message) {
|
||||
const reject = this.walletPaymentWatchPrompt.reject
|
||||
this.walletPaymentWatchPrompt = this.emptyWalletPaymentWatchPrompt()
|
||||
reject?.(new Error(message))
|
||||
},
|
||||
positiveInteger(value, fallback) {
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number) || number <= 0) return fallback
|
||||
return Math.floor(number)
|
||||
},
|
||||
backgroundPaymentDestinationPolicy(value) {
|
||||
return value === 'external_allowed'
|
||||
? 'external_allowed'
|
||||
: 'own_wallets_only'
|
||||
},
|
||||
async postExtensionPermission(path, body, fallbackMessage) {
|
||||
const response = await fetch(
|
||||
`/api/v1/extension/${encodeURIComponent(
|
||||
this.bridge.extensionId
|
||||
)}/permissions/${path}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {'content-type': 'application/json'},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
)
|
||||
const text = await response.text()
|
||||
let data = {}
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (_error) {
|
||||
data = {detail: text}
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.detail || fallbackMessage)
|
||||
}
|
||||
return data
|
||||
},
|
||||
notifyApprovedPermissionUse(permissions) {
|
||||
const permissionList = permissions.filter(Boolean).join(', ')
|
||||
if (!permissionList) return
|
||||
this.notify({
|
||||
level: 'info',
|
||||
message: `Using approved permissions: ${permissionList}.`
|
||||
})
|
||||
},
|
||||
requireCameraScanApproval() {
|
||||
if (this.isCameraScanRemembered()) return Promise.resolve()
|
||||
if (this.cameraPrompt.show) {
|
||||
@@ -974,30 +515,6 @@ window.WasmExtensionComponent = {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'permissions.request') {
|
||||
this.sendResponse(reply, message.id, {
|
||||
ok: true,
|
||||
data: await this.requestExtensionPermissions(message)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'permissions.request_background_payment') {
|
||||
this.sendResponse(reply, message.id, {
|
||||
ok: true,
|
||||
data: await this.requestBackgroundPaymentPermission(message)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'permissions.request_wallet_payment_watch') {
|
||||
this.sendResponse(reply, message.id, {
|
||||
ok: true,
|
||||
data: await this.requestWalletPaymentWatchPermission(message)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'payment.subscribe') {
|
||||
this.subscribePayment(message)
|
||||
this.sendResponse(reply, message.id, {
|
||||
|
||||
@@ -67,8 +67,6 @@
|
||||
"js/components/admin/lnbits-admin-users.js",
|
||||
"js/components/admin/lnbits-admin-server.js",
|
||||
"js/components/admin/lnbits-admin-extensions.js",
|
||||
"js/components/admin/lnbits-admin-wasm-runtime.js",
|
||||
"js/components/admin/lnbits-admin-wasm-limit-config.js",
|
||||
"js/components/admin/lnbits-admin-notifications.js",
|
||||
"js/components/admin/lnbits-admin-site-customisation.js",
|
||||
"js/components/admin/lnbits-admin-assets-config.js",
|
||||
|
||||
@@ -8,8 +8,6 @@ include('components/admin/users.vue') %} {%
|
||||
include('components/admin/site_customisation.vue') %} {%
|
||||
include('components/admin/audit.vue') %} {%
|
||||
include('components/admin/extensions.vue') %} {%
|
||||
include('components/admin/wasm-runtime.vue') %} {%
|
||||
include('components/admin/wasm-limit-config.vue') %} {%
|
||||
include('components/admin/assets-config.vue') %} {%
|
||||
include('components/admin/notifications.vue') %} {%
|
||||
include('components/admin/server.vue') %} {%
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<template id="lnbits-admin-extensions">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div>
|
||||
<div class="row items-center justify-between q-mb-md">
|
||||
<h6 class="q-my-none">
|
||||
<span v-text="$t('extensions')"></span>
|
||||
</h6>
|
||||
</div>
|
||||
<h6 class="q-my-none">
|
||||
<span v-text="$t('extensions')"></span>
|
||||
</h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 q-mb-md">
|
||||
<p>
|
||||
@@ -35,27 +33,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 q-mb-md">
|
||||
<p>Wasm Extension</p>
|
||||
<div class="row q-gutter-sm">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
icon="memory"
|
||||
label="WASM Runtime"
|
||||
to="/admin/extensions/wasm"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
icon="tune"
|
||||
label="Wasm Limit Config"
|
||||
to="/admin/extensions/wasm/limits"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>
|
||||
@@ -147,15 +124,6 @@
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-input
|
||||
class="q-mt-md"
|
||||
filled
|
||||
v-model.number="formData.lnbits_wasm_invocation_retention_days"
|
||||
type="number"
|
||||
min="0"
|
||||
label="WASM invocation retention days"
|
||||
hint="Set to 0 to disable automatic cleanup."
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
<template id="lnbits-admin-wasm-limit-config">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div>
|
||||
<div class="row items-center justify-between q-mb-md q-col-gutter-sm">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-btn flat dense round icon="arrow_back" :to="backRoute">
|
||||
<q-tooltip v-text="backTooltip"></q-tooltip>
|
||||
</q-btn>
|
||||
<div>
|
||||
<h6 class="q-my-none">Wasm Limit Config</h6>
|
||||
<div
|
||||
class="text-caption text-grey-7"
|
||||
v-text="pageDescription"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn-dropdown
|
||||
unelevated
|
||||
color="primary"
|
||||
icon="tune"
|
||||
label="Custom Extension Limit"
|
||||
:loading="wasmRuntimeLimitExtensionsLoading"
|
||||
>
|
||||
<q-list style="min-width: 280px; max-width: min(420px, 90vw)">
|
||||
<q-item-label header>
|
||||
Select an extension from the list to customize
|
||||
</q-item-label>
|
||||
<q-item
|
||||
v-if="
|
||||
!wasmRuntimeLimitExtensionsLoading &&
|
||||
wasmRuntimeLimitExtensionOptions.length === 0
|
||||
"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>No installed WASM extensions found.</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item
|
||||
v-for="extension in wasmRuntimeLimitExtensionOptions"
|
||||
:key="extension.value"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="openWasmExtensionLimit(extension.value)"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="extension.label"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
|
||||
<template v-if="!isExtensionLimitRoute">
|
||||
<div
|
||||
v-for="(group, index) in wasmRuntimeLimitGroups"
|
||||
:key="group.title"
|
||||
class="q-mb-md"
|
||||
>
|
||||
<q-expansion-item
|
||||
expand-separator
|
||||
:default-opened="group.title === 'Execution'"
|
||||
:label="group.title"
|
||||
header-class="text-subtitle2 text-weight-medium"
|
||||
>
|
||||
<div class="row q-col-gutter-md q-pt-md">
|
||||
<div
|
||||
v-for="field in group.fields"
|
||||
:key="field.name"
|
||||
class="col-12 col-md-6"
|
||||
>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
min="0"
|
||||
v-model.number="formData[field.name]"
|
||||
:label="field.label"
|
||||
:hint="field.description"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
icon="info"
|
||||
color="primary"
|
||||
@click.stop.prevent="showWasmLimitInfo(field)"
|
||||
>
|
||||
<q-tooltip max-width="360px">
|
||||
<span v-text="field.details"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
<q-separator
|
||||
v-if="index < wasmRuntimeLimitGroups.length - 1"
|
||||
class="q-mt-md"
|
||||
></q-separator>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="isExtensionLimitRoute"
|
||||
class="row items-center justify-between q-mb-md"
|
||||
>
|
||||
<div>
|
||||
<div class="text-subtitle1 text-weight-medium">
|
||||
Extension overrides
|
||||
</div>
|
||||
<div class="text-caption text-grey-7">
|
||||
Empty values inherit the global defaults. A saved 0 disables that
|
||||
limit for the selected extension.
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedWasmRuntimeLimitExtension" class="col-12 col-md-7">
|
||||
<div class="row items-center q-gutter-sm full-height">
|
||||
<q-chip
|
||||
dense
|
||||
:color="
|
||||
selectedWasmRuntimeLimitExtension.active ? 'positive' : 'grey-7'
|
||||
"
|
||||
text-color="white"
|
||||
:label="
|
||||
selectedWasmRuntimeLimitExtension.active ? 'Active' : 'Inactive'
|
||||
"
|
||||
></q-chip>
|
||||
<q-chip
|
||||
dense
|
||||
outline
|
||||
color="primary"
|
||||
:label="customWasmLimitCount + ' custom overrides'"
|
||||
></q-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-banner
|
||||
v-if="
|
||||
isExtensionLimitRoute &&
|
||||
!wasmRuntimeLimitExtensionsLoading &&
|
||||
!selectedWasmRuntimeLimitExtension
|
||||
"
|
||||
rounded
|
||||
class="bg-grey-2 text-grey-8 q-mb-lg"
|
||||
>
|
||||
WASM extension not found.
|
||||
</q-banner>
|
||||
|
||||
<div v-if="isExtensionLimitRoute && selectedWasmRuntimeLimitExtension">
|
||||
<div
|
||||
v-for="(group, index) in wasmRuntimeLimitGroups"
|
||||
:key="'extension-' + group.title"
|
||||
class="q-mb-md"
|
||||
>
|
||||
<q-expansion-item
|
||||
expand-separator
|
||||
:default-opened="group.title === 'Execution'"
|
||||
:label="group.title"
|
||||
header-class="text-subtitle2 text-weight-medium"
|
||||
>
|
||||
<div class="row q-col-gutter-md q-pt-md">
|
||||
<div
|
||||
v-for="field in group.fields"
|
||||
:key="'extension-' + field.name"
|
||||
class="col-12 col-md-6"
|
||||
>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="wasmExtensionLimitDraft[field.name]"
|
||||
:label="field.label"
|
||||
:hint="wasmExtensionLimitHint(field)"
|
||||
:placeholder="wasmExtensionLimitPlaceholder(field)"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
icon="info"
|
||||
color="primary"
|
||||
@click.stop.prevent="showWasmLimitInfo(field)"
|
||||
>
|
||||
<q-tooltip max-width="360px">
|
||||
<span v-text="field.details"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
<q-separator
|
||||
v-if="index < wasmRuntimeLimitGroups.length - 1"
|
||||
class="q-mt-md"
|
||||
></q-separator>
|
||||
</div>
|
||||
|
||||
<div class="row justify-end q-gutter-sm q-mt-md">
|
||||
<q-btn
|
||||
flat
|
||||
color="primary"
|
||||
icon="restart_alt"
|
||||
label="Clear Overrides"
|
||||
:disable="wasmExtensionLimitsSaving"
|
||||
@click="clearWasmExtensionLimits"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
icon="save"
|
||||
label="Save Extension Limits"
|
||||
:loading="wasmExtensionLimitsSaving"
|
||||
@click="saveWasmExtensionLimits"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="wasmLimitInfoDialog.show">
|
||||
<q-card style="width: min(560px, calc(100vw - 32px)); max-width: 560px">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-h6" v-text="wasmLimitInfoDialog.title"></div>
|
||||
<q-space></q-space>
|
||||
<q-btn v-close-popup flat round dense icon="close"></q-btn>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div
|
||||
class="text-body1"
|
||||
style="line-height: 1.6"
|
||||
v-text="wasmLimitInfoDialog.details"
|
||||
></div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</template>
|
||||
@@ -1,245 +0,0 @@
|
||||
<template id="lnbits-admin-wasm-runtime">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div>
|
||||
<div class="row items-center justify-between q-mb-md">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="arrow_back"
|
||||
:to="
|
||||
wasmExtensionId ? '/admin/extensions/wasm' : '/admin#extensions'
|
||||
"
|
||||
>
|
||||
<q-tooltip
|
||||
v-text="
|
||||
wasmExtensionId ? 'Global WASM Runtime' : 'Extensions Settings'
|
||||
"
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
<div>
|
||||
<h6 class="q-my-none">WASM Runtime</h6>
|
||||
<div
|
||||
v-if="wasmExtensionId"
|
||||
class="text-caption text-grey-7"
|
||||
v-text="`Extension: ${wasmExtensionId}`"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
icon="refresh"
|
||||
:loading="wasmRuntimeLoading"
|
||||
@click="fetchWasmRuntime"
|
||||
>
|
||||
<q-tooltip>Refresh runtime data</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div
|
||||
v-for="item in wasmStatItems"
|
||||
:key="item.key"
|
||||
class="col-6 col-sm-4 col-md-2"
|
||||
>
|
||||
<q-card flat bordered class="full-height">
|
||||
<q-card-section class="q-pa-sm">
|
||||
<div class="row items-center no-wrap q-gutter-sm">
|
||||
<q-avatar
|
||||
rounded
|
||||
size="34px"
|
||||
:color="item.color"
|
||||
text-color="white"
|
||||
:icon="item.icon"
|
||||
></q-avatar>
|
||||
<div class="col">
|
||||
<div
|
||||
class="text-caption text-grey-7"
|
||||
v-text="item.label"
|
||||
></div>
|
||||
<div
|
||||
class="text-h6 text-weight-bold"
|
||||
v-text="formatWasmStat(item.key)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
class="q-mb-lg"
|
||||
dense
|
||||
flat
|
||||
wrap-cells
|
||||
:rows="wasmCurrentInvocations"
|
||||
:columns="wasmCurrentColumns"
|
||||
row-key="id"
|
||||
:loading="wasmRuntimeLoading"
|
||||
:pagination="{rowsPerPage: 5}"
|
||||
table-style="table-layout: fixed; width: 100%"
|
||||
title="Current Invocations"
|
||||
>
|
||||
<template v-slot:body-cell-extension_id="props">
|
||||
<q-td :props="props">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
no-caps
|
||||
color="primary"
|
||||
:label="props.row.extension_id"
|
||||
:to="`/admin/extensions/wasm/${encodeURIComponent(props.row.extension_id)}`"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-trigger_type="props">
|
||||
<q-td :props="props">
|
||||
<q-badge
|
||||
outline
|
||||
:color="wasmTriggerColor(props.row.trigger_type)"
|
||||
v-text="props.row.trigger_type"
|
||||
></q-badge>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-status="props">
|
||||
<q-td :props="props">
|
||||
<q-badge
|
||||
:color="wasmStatusColor(props.row.status)"
|
||||
v-text="props.row.status"
|
||||
></q-badge>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-user_id="props">
|
||||
<q-td :props="props">
|
||||
<span v-if="props.row.user_id">
|
||||
<span v-text="formatWasmUserId(props.row.user_id)"></span>
|
||||
<q-tooltip v-text="props.row.user_id"></q-tooltip>
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-started_at="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
v-text="formatWasmDate(props.row.started_at)"
|
||||
></q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-duration_ms="props">
|
||||
<q-td :props="props" v-text="formatWasmDuration(props.row)"></q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-context="props">
|
||||
<q-td :props="props">
|
||||
<div
|
||||
style="white-space: normal; word-break: break-word"
|
||||
v-text="formatWasmContext(props.row)"
|
||||
></div>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-actions="props">
|
||||
<q-td :props="props">
|
||||
<div class="row justify-end q-gutter-xs">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
color="negative"
|
||||
icon="stop_circle"
|
||||
@click="stopWasmInvocation(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Stop Invocation</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
dense
|
||||
unelevated
|
||||
color="negative"
|
||||
label="Deactivate Extension"
|
||||
@click="deactivateWasmExtension(props.row.extension_id)"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
wrap-cells
|
||||
:rows="wasmInvocationHistory"
|
||||
:columns="wasmHistoryColumns"
|
||||
row-key="id"
|
||||
:loading="wasmHistoryLoading"
|
||||
:pagination="{rowsPerPage: 10}"
|
||||
table-style="table-layout: fixed; width: 100%"
|
||||
title="Recent Invocations"
|
||||
>
|
||||
<template v-slot:body-cell-extension_id="props">
|
||||
<q-td :props="props">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
no-caps
|
||||
color="primary"
|
||||
:label="props.row.extension_id"
|
||||
:to="`/admin/extensions/wasm/${encodeURIComponent(props.row.extension_id)}`"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-trigger_type="props">
|
||||
<q-td :props="props">
|
||||
<q-badge
|
||||
outline
|
||||
:color="wasmTriggerColor(props.row.trigger_type)"
|
||||
v-text="props.row.trigger_type"
|
||||
></q-badge>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-status="props">
|
||||
<q-td :props="props">
|
||||
<q-badge
|
||||
:color="wasmStatusColor(props.row.status)"
|
||||
v-text="props.row.status"
|
||||
></q-badge>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-user_id="props">
|
||||
<q-td :props="props">
|
||||
<span v-if="props.row.user_id">
|
||||
<span v-text="formatWasmUserId(props.row.user_id)"></span>
|
||||
<q-tooltip v-text="props.row.user_id"></q-tooltip>
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-started_at="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
v-text="formatWasmDate(props.row.started_at)"
|
||||
></q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-duration_ms="props">
|
||||
<q-td :props="props" v-text="formatWasmDuration(props.row)"></q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-calls="props">
|
||||
<q-td :props="props" v-text="formatWasmCalls(props.row)"></q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-context="props">
|
||||
<q-td :props="props">
|
||||
<div
|
||||
style="white-space: normal; word-break: break-word"
|
||||
v-text="formatWasmContext(props.row)"
|
||||
></div>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-error_message="props">
|
||||
<q-td :props="props">
|
||||
<span
|
||||
style="white-space: normal; word-break: break-word"
|
||||
v-text="props.row.error_message || props.row.stop_reason || ''"
|
||||
></span>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</template>
|
||||
@@ -74,13 +74,7 @@
|
||||
<div class="col q-gutter-y-md">
|
||||
<q-card>
|
||||
<!-- Mobile: Dropdown menu at top -->
|
||||
<div
|
||||
v-if="
|
||||
$q.screen.lt.md &&
|
||||
!['wasm-runtime', 'wasm-limit-config'].includes(tab)
|
||||
"
|
||||
class="q-px-md q-pt-md"
|
||||
>
|
||||
<div v-if="$q.screen.lt.md" class="q-px-md q-pt-md">
|
||||
<q-select
|
||||
v-model="tab"
|
||||
:options="[
|
||||
@@ -226,12 +220,6 @@
|
||||
<q-tab-panel name="extensions">
|
||||
<lnbits-admin-extensions :form-data="formData" />
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="wasm-runtime">
|
||||
<lnbits-admin-wasm-runtime :form-data="formData" />
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="wasm-limit-config">
|
||||
<lnbits-admin-wasm-limit-config :form-data="formData" />
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="notifications">
|
||||
<lnbits-admin-notifications :form-data="formData" />
|
||||
</q-tab-panel>
|
||||
|
||||
@@ -356,7 +356,7 @@
|
||||
@click="showManageExtension(extension)"
|
||||
flat
|
||||
color="primary"
|
||||
v-if="canShowManageExtensionButton(extension)"
|
||||
v-if="g.user.admin"
|
||||
:label="$t('manage')"
|
||||
><q-tooltip
|
||||
><span v-text="$t('manage_extension_details')"></span
|
||||
@@ -531,43 +531,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
<q-card
|
||||
v-else
|
||||
class="q-pa-lg lnbits__dialog-card"
|
||||
style="width: min(95vw, 920px); max-width: 95vw"
|
||||
>
|
||||
<q-card v-else class="q-pa-lg lnbits__dialog-card">
|
||||
<q-tabs
|
||||
v-model="manageExtensionTab"
|
||||
active-color="primary"
|
||||
align="justify"
|
||||
>
|
||||
<q-tab
|
||||
v-if="canShowAdminManageTabs()"
|
||||
name="releases"
|
||||
:label="$t('releases')"
|
||||
@update="val => (manageExtensionTab = val.name)"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
v-if="canManageExtensionPermissions()"
|
||||
name="extension-permissions"
|
||||
:label="$t('extension_permissions_tab')"
|
||||
@update="val => (manageExtensionTab = val.name)"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
v-if="canManageExtensionPermissions()"
|
||||
name="user-permissions"
|
||||
:label="$t('user_permissions_tab')"
|
||||
@update="val => (manageExtensionTab = val.name)"
|
||||
></q-tab>
|
||||
|
||||
<q-tab
|
||||
v-if="
|
||||
canShowAdminManageTabs() &&
|
||||
selectedExtension &&
|
||||
selectedExtension.isInstalled
|
||||
"
|
||||
v-if="selectedExtension && selectedExtension.isInstalled"
|
||||
name="sell"
|
||||
:label="$t('sell')"
|
||||
@update="val => (manageExtensionTab = val.name)"
|
||||
@@ -577,7 +554,7 @@
|
||||
<div
|
||||
v-show="manageExtensionTab === 'releases'"
|
||||
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
|
||||
v-if="canShowAdminManageTabs() && selectedExtensionRepos"
|
||||
v-if="selectedExtensionRepos"
|
||||
>
|
||||
<q-card
|
||||
flat
|
||||
@@ -838,147 +815,9 @@
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-spinner-bars
|
||||
v-else-if="
|
||||
canShowAdminManageTabs() && manageExtensionTab === 'releases'
|
||||
"
|
||||
color="primary"
|
||||
size="2.55em"
|
||||
></q-spinner-bars>
|
||||
<q-spinner-bars v-else color="primary" size="2.55em"></q-spinner-bars>
|
||||
<div
|
||||
v-if="canManageExtensionPermissions()"
|
||||
v-show="manageExtensionTab === 'extension-permissions'"
|
||||
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
|
||||
>
|
||||
<q-spinner-bars
|
||||
v-if="managedExtensionPermissions.loading"
|
||||
color="primary"
|
||||
size="2.55em"
|
||||
></q-spinner-bars>
|
||||
<lnbits-extension-permissions
|
||||
v-else-if="managedExtensionPermissions.extensionPermissions.length"
|
||||
:permissions="managedExtensionPermissions.extensionPermissions"
|
||||
:extensions="extensions"
|
||||
></lnbits-extension-permissions>
|
||||
<q-banner v-else rounded class="bg-grey-2 text-grey-8">
|
||||
<span v-text="$t('extension_permissions_none')"></span>
|
||||
</q-banner>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="canManageExtensionPermissions()"
|
||||
v-show="manageExtensionTab === 'user-permissions'"
|
||||
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
|
||||
>
|
||||
<q-spinner-bars
|
||||
v-if="managedExtensionPermissions.loading"
|
||||
color="primary"
|
||||
size="2.55em"
|
||||
></q-spinner-bars>
|
||||
<q-banner
|
||||
v-else-if="!managedUserPermissionRows.length"
|
||||
rounded
|
||||
class="bg-grey-2 text-grey-8"
|
||||
>
|
||||
<span v-text="$t('user_permissions_none')"></span>
|
||||
</q-banner>
|
||||
<q-list v-else bordered separator>
|
||||
<q-expansion-item
|
||||
v-for="row in managedUserPermissionRows"
|
||||
:key="row.key"
|
||||
>
|
||||
<template v-slot:header>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-medium">
|
||||
<span v-text="row.label"></span>
|
||||
</q-item-label>
|
||||
<q-item-label caption>
|
||||
<span v-text="userPermissionRowCaption(row)"></span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
color="negative"
|
||||
icon="delete"
|
||||
:loading="managedExtensionPermissions.deletingKey === row.key"
|
||||
@click.stop="deleteUserPermissionGrant(row)"
|
||||
>
|
||||
<q-tooltip>
|
||||
<span v-text="$t('delete')"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</q-item-section>
|
||||
</template>
|
||||
<div class="q-pa-md">
|
||||
<div
|
||||
v-if="isBackgroundPaymentPermission(row)"
|
||||
class="row q-col-gutter-sm"
|
||||
>
|
||||
<div class="col-12 col-sm-5">
|
||||
<q-input
|
||||
dense
|
||||
outlined
|
||||
type="number"
|
||||
min="1"
|
||||
v-model.number="row.grant.max_amount"
|
||||
:label="$t('user_permissions_max_amount')"
|
||||
suffix="sat"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-sm-7">
|
||||
<q-select
|
||||
dense
|
||||
outlined
|
||||
emit-value
|
||||
map-options
|
||||
v-model="row.grant.destination_policy"
|
||||
:options="backgroundPaymentDestinationOptions"
|
||||
:label="$t('user_permissions_destination_policy')"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
icon="save"
|
||||
:loading="managedExtensionPermissions.savingKey === row.key"
|
||||
@click="saveUserPermissionGrant(row)"
|
||||
>
|
||||
<span v-text="$t('save')"></span>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-item-label v-else caption>
|
||||
<span
|
||||
v-text="$t('user_permissions_no_editable_settings')"
|
||||
></span>
|
||||
</q-item-label>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-close-popup
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
v-text="$t('close')"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="canShowAdminManageTabs() && selectedExtension"
|
||||
v-if="selectedExtension"
|
||||
v-show="manageExtensionTab === 'sell'"
|
||||
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
|
||||
>
|
||||
|
||||
@@ -664,8 +664,7 @@
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="payInvoice"
|
||||
:disable="parse.sending"
|
||||
:label="parse.sending ? $t('sending') + '...' : $t('pay')"
|
||||
:label="$t('pay')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
v-close-popup
|
||||
@@ -837,13 +836,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
:disable="parse.sending"
|
||||
:label="parse.sending ? $t('sending') + '...' : $t('send')"
|
||||
></q-btn>
|
||||
<q-btn unelevated color="primary" type="submit">Send</q-btn>
|
||||
<q-btn
|
||||
:label="$t('cancel')"
|
||||
v-close-popup
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from .base import (
|
||||
@@ -38,3 +40,6 @@ class VoidWallet(Wallet):
|
||||
|
||||
async def get_payment_status(self, *_, **__) -> PaymentStatus:
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
yield ""
|
||||
|
||||
@@ -120,8 +120,6 @@
|
||||
"js/components/admin/lnbits-admin-users.js",
|
||||
"js/components/admin/lnbits-admin-server.js",
|
||||
"js/components/admin/lnbits-admin-extensions.js",
|
||||
"js/components/admin/lnbits-admin-wasm-runtime.js",
|
||||
"js/components/admin/lnbits-admin-wasm-limit-config.js",
|
||||
"js/components/admin/lnbits-admin-notifications.js",
|
||||
"js/components/admin/lnbits-admin-site-customisation.js",
|
||||
"js/components/admin/lnbits-admin-assets-config.js",
|
||||
|
||||
@@ -83,8 +83,7 @@ async def test_admin_audit_monitor_and_test_email(
|
||||
)
|
||||
assert monitor.status_code == 200
|
||||
task_names = [t["name"] for t in monitor.json()]
|
||||
assert "core_invoice_listener" in task_names
|
||||
assert "core_wasm_invoice_listener" in task_names
|
||||
assert any("invoice_listener" in name for name in task_names)
|
||||
|
||||
test_email = await client.get(
|
||||
"/admin/api/v1/testemail",
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -11,7 +8,6 @@ from starlette.requests import Request
|
||||
from lnbits.core.crud.db_versions import get_db_version, update_migration_version
|
||||
from lnbits.core.crud.extensions import (
|
||||
create_installed_extension,
|
||||
delete_installed_extension,
|
||||
get_installed_extension,
|
||||
get_user_extension,
|
||||
)
|
||||
@@ -23,14 +19,11 @@ from lnbits.core.models.extensions import (
|
||||
CreateExtensionReview,
|
||||
Extension,
|
||||
ExtensionConfig,
|
||||
ExtensionPermission,
|
||||
ExtensionRelease,
|
||||
InstallableExtension,
|
||||
PayToEnableInfo,
|
||||
ReleasePaymentInfo,
|
||||
UserExtensionInfo,
|
||||
WasmRuntimeLimitsUpdate,
|
||||
wasm_extension_icon_url,
|
||||
)
|
||||
from lnbits.core.models.users import AccountId
|
||||
from lnbits.core.services.payments import create_wallet_invoice
|
||||
@@ -42,11 +35,9 @@ from lnbits.core.views.extension_api import (
|
||||
api_enable_extension,
|
||||
api_extension_details,
|
||||
api_get_user_extensions,
|
||||
api_get_wasm_runtime_limit_extensions,
|
||||
api_install_extension,
|
||||
api_uninstall_extension,
|
||||
api_update_pay_to_enable,
|
||||
api_update_wasm_runtime_limits,
|
||||
create_extension_review,
|
||||
delete_extension_db,
|
||||
extensions,
|
||||
@@ -171,147 +162,6 @@ async def test_extension_api_install_details_and_release_endpoints(mocker):
|
||||
assert release_info["is_version_compatible"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_extension_api_installs_wasm_with_granted_permissions(
|
||||
tmp_path,
|
||||
settings,
|
||||
mocker,
|
||||
):
|
||||
ext_id = f"wasm_{uuid4().hex[:8]}"
|
||||
release = make_extension_release(ext_id)
|
||||
granted_permissions = [
|
||||
ExtensionPermission(
|
||||
id="http.request",
|
||||
policies=[{"host": "https://api.example.com"}],
|
||||
)
|
||||
]
|
||||
create_data = CreateExtension(
|
||||
ext_id=ext_id,
|
||||
archive=release.archive,
|
||||
source_repo=release.source_repo,
|
||||
version=release.version,
|
||||
permissions=granted_permissions,
|
||||
)
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
register_wasm_routes_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions.core_app_extra.register_new_wasm_ext_routes"
|
||||
)
|
||||
mocker.patch.object(
|
||||
InstallableExtension,
|
||||
"get_extension_release",
|
||||
mocker.AsyncMock(return_value=release),
|
||||
)
|
||||
mocker.patch.object(
|
||||
InstallableExtension,
|
||||
"download_archive",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
|
||||
try:
|
||||
settings.lnbits_data_folder = str(tmp_path / "data")
|
||||
settings.lnbits_extensions_path = str(tmp_path / "code")
|
||||
_write_wasm_extension_archive(ext_id, release.version, settings)
|
||||
|
||||
installed = await api_install_extension(create_data)
|
||||
stored = await get_installed_extension(ext_id)
|
||||
finally:
|
||||
await delete_installed_extension(ext_id=ext_id)
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
|
||||
assert installed.code == ext_id
|
||||
assert installed.is_wasm is True
|
||||
assert stored is not None
|
||||
assert stored.permissions == [
|
||||
ExtensionPermission(
|
||||
id="http.request",
|
||||
description="Call example API.",
|
||||
policies=[{"host": "https://api.example.com"}],
|
||||
)
|
||||
]
|
||||
register_wasm_routes_mock.assert_called_once_with(ext_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_extension_api_wasm_runtime_limits_and_catalog_use_installed_metadata(
|
||||
tmp_path,
|
||||
settings,
|
||||
mocker,
|
||||
):
|
||||
ext_id = f"wasm_{uuid4().hex[:8]}"
|
||||
py_ext_id = f"py_{uuid4().hex[:8]}"
|
||||
granted_permissions = [
|
||||
ExtensionPermission(
|
||||
id="http.request",
|
||||
description="Call example API.",
|
||||
policies=[{"host": "https://api.example.com"}],
|
||||
)
|
||||
]
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
|
||||
try:
|
||||
settings.lnbits_extensions_path = str(tmp_path)
|
||||
_write_installed_wasm_config(ext_id, tmp_path)
|
||||
await create_installed_extension(
|
||||
InstallableExtension(
|
||||
id=ext_id,
|
||||
name="WASM Demo",
|
||||
version="1.0.0",
|
||||
active=True,
|
||||
permissions=granted_permissions,
|
||||
wasm_runtime_limits={"wasm_runtime_max_execution_ms": 1234},
|
||||
)
|
||||
)
|
||||
await create_installed_extension(make_installable_extension(py_ext_id))
|
||||
|
||||
runtime_extensions = await api_get_wasm_runtime_limit_extensions()
|
||||
wasm_info = next(info for info in runtime_extensions if info.id == ext_id)
|
||||
|
||||
updated_info = await api_update_wasm_runtime_limits(
|
||||
ext_id,
|
||||
WasmRuntimeLimitsUpdate(
|
||||
limits={
|
||||
"wasm_runtime_max_execution_ms": "2345",
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
}
|
||||
),
|
||||
)
|
||||
stored = await get_installed_extension(ext_id)
|
||||
|
||||
mocker.patch.object(
|
||||
InstallableExtension,
|
||||
"get_installable_extensions",
|
||||
mocker.AsyncMock(
|
||||
return_value=[
|
||||
make_installable_extension(ext_id),
|
||||
make_installable_extension(py_ext_id),
|
||||
]
|
||||
),
|
||||
)
|
||||
catalog = await extensions(AccountId(id=uuid4().hex))
|
||||
finally:
|
||||
await delete_installed_extension(ext_id=ext_id)
|
||||
await delete_installed_extension(ext_id=py_ext_id)
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
|
||||
assert wasm_info.wasm_runtime_limits == {"wasm_runtime_max_execution_ms": 1234}
|
||||
assert py_ext_id not in {info.id for info in runtime_extensions}
|
||||
assert updated_info.wasm_runtime_limits == {
|
||||
"wasm_runtime_max_execution_ms": 2345,
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
}
|
||||
assert stored is not None
|
||||
assert stored.wasm_runtime_limits == updated_info.wasm_runtime_limits
|
||||
|
||||
catalog_item = next(item for item in catalog if item["id"] == ext_id)
|
||||
assert catalog_item["isWasm"] is True
|
||||
assert catalog_item["icon"] == wasm_extension_icon_url(ext_id)
|
||||
assert catalog_item["permissions"] == [
|
||||
dict(permission) for permission in granted_permissions
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_extension_api_pay_to_enable_and_catalog_views(mocker, admin_user):
|
||||
regular_user = await create_user_account(
|
||||
@@ -578,40 +428,3 @@ async def test_extension_api_review_endpoints(mocker):
|
||||
CreateExtensionReview(tag=ext_id, name="Alice", rating=900, comment="Great")
|
||||
)
|
||||
assert payment_request.payment_hash.startswith("hash_")
|
||||
|
||||
|
||||
def _write_wasm_extension_archive(ext_id: str, version: str, settings) -> None:
|
||||
zip_path = Path(settings.lnbits_data_folder, "zips", f"{ext_id}.zip")
|
||||
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config = _wasm_config(ext_id)
|
||||
root = f"{ext_id}-{version}"
|
||||
with zipfile.ZipFile(zip_path, "w") as archive:
|
||||
archive.writestr(f"{root}/config.json", json.dumps(config))
|
||||
archive.writestr(f"{root}/{config['wasm']['module']}", b"\0asm")
|
||||
|
||||
|
||||
def _write_installed_wasm_config(ext_id: str, extensions_path) -> None:
|
||||
config_dir = extensions_path / "extensions" / ext_id
|
||||
config_dir.mkdir(parents=True)
|
||||
(config_dir / "config.json").write_text(
|
||||
json.dumps(_wasm_config(ext_id)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _wasm_config(ext_id: str) -> dict:
|
||||
return {
|
||||
"id": ext_id,
|
||||
"name": "WASM Demo",
|
||||
"short_description": "WASM extension",
|
||||
"version": "1.0.0",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {"module": "extension.wasm"},
|
||||
"permissions": [
|
||||
{
|
||||
"id": "http.request",
|
||||
"description": "Call example API.",
|
||||
"policies": [{"host": "https://api.example.com"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ def run_before_and_after_tests(settings: Settings):
|
||||
@pytest.fixture(scope="session")
|
||||
async def app(settings: Settings):
|
||||
app = create_app()
|
||||
async with LifespanManager(app, startup_timeout=30) as manager:
|
||||
async with LifespanManager(app) as manager:
|
||||
settings.first_install = True
|
||||
await first_install(
|
||||
UpdateSuperuserPassword(
|
||||
|
||||
+3
-35
@@ -1,4 +1,4 @@
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import date, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -9,41 +9,9 @@ from lnbits.core.crud import (
|
||||
get_wallet_for_key,
|
||||
)
|
||||
from lnbits.core.crud.payments import get_payment
|
||||
from lnbits.core.models import CreateInvoice, PaymentFilters
|
||||
from lnbits.core.models import CreateInvoice
|
||||
from lnbits.core.services.payments import create_wallet_invoice
|
||||
from lnbits.db import POSTGRES, SQLITE, Filter, Filters
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("db_type", "lower_statement", "upper_statement"),
|
||||
[
|
||||
(
|
||||
POSTGRES,
|
||||
"(time >= to_timestamp(:time__0_0))",
|
||||
"(time <= to_timestamp(:time__1_0))",
|
||||
),
|
||||
(SQLITE, "(time >= :time__0_0)", "(time <= :time__1_0)"),
|
||||
],
|
||||
)
|
||||
def test_datetime_filter_uses_database_timestamp_placeholder(
|
||||
monkeypatch, db_type, lower_statement, upper_statement
|
||||
):
|
||||
monkeypatch.setattr("lnbits.db.DB_TYPE", db_type)
|
||||
lower_bound = Filter.parse_query(
|
||||
"time[ge]", ["2026-06-16T00:00:00"], PaymentFilters, 0
|
||||
)
|
||||
upper_bound = Filter.parse_query(
|
||||
"time[le]", ["2026-06-23T23:59:59"], PaymentFilters, 1
|
||||
)
|
||||
filters = Filters(filters=[lower_bound, upper_bound], model=PaymentFilters)
|
||||
values = filters.values()
|
||||
|
||||
assert isinstance(values["time__0_0"], datetime)
|
||||
assert values["time__0_0"] == datetime(2026, 6, 16)
|
||||
assert values["time__1_0"] == datetime(2026, 6, 23, 23, 59, 59)
|
||||
assert filters.where() == f"WHERE {lower_statement} AND {upper_statement}"
|
||||
assert lower_bound.statement == lower_statement
|
||||
assert upper_bound.statement == upper_statement
|
||||
from lnbits.db import POSTGRES
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -12,7 +12,6 @@ from lnbits.core.crud.users import delete_account
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.models.users import AccessTokenPayload
|
||||
from lnbits.decorators import (
|
||||
_extension_id_from_request_path,
|
||||
access_token_payload,
|
||||
check_access_token,
|
||||
check_admin_ui,
|
||||
@@ -226,12 +225,3 @@ async def test_check_extension_builder_requires_admin_when_disabled_for_users(
|
||||
admin_user = user_alan.copy(deep=True)
|
||||
admin_user.admin = True
|
||||
await check_extension_builder(admin_user)
|
||||
|
||||
|
||||
def test_extension_id_from_request_path_handles_wasm_routes():
|
||||
assert _extension_id_from_request_path("/ext/wasm_demo") == "wasm_demo"
|
||||
assert _extension_id_from_request_path("/ext/wasm_demo/page/1") == "wasm_demo"
|
||||
assert (
|
||||
_extension_id_from_request_path("/api/v1/ext/wasm_demo/invoices") == "wasm_demo"
|
||||
)
|
||||
assert _extension_id_from_request_path("/lnurlp/api/v1") == "lnurlp"
|
||||
|
||||
@@ -1433,7 +1433,7 @@ def test_check_revolut_signature_multiple_v1_headers():
|
||||
check_revolut_signature(payload, sig_header, timestamp, secret)
|
||||
|
||||
|
||||
def test_check_revolut_signature_docs_vector(mocker: MockerFixture):
|
||||
def test_check_revolut_signature_docs_vector():
|
||||
payload = (
|
||||
b'{"data":{"id":"645a7696-22f3-aa47-9c74-cbae0449cc46",'
|
||||
b'"new_state":"completed","old_state":"pending",'
|
||||
@@ -1445,16 +1445,9 @@ def test_check_revolut_signature_docs_vector(mocker: MockerFixture):
|
||||
secret = "wsk_r59a4HfWVAKycbCaNO1RvgCJec02gRd8"
|
||||
sig = "v1=bca326fb378d0da7f7c490ad584a8106bab9723d8d9cdd0d50b4c5b3be3837c0"
|
||||
|
||||
# This is a fixed vector straight from Revolut's docs, so its timestamp is
|
||||
# necessarily in the past. Freeze time to it instead of growing
|
||||
# tolerance_seconds indefinitely as real time marches on.
|
||||
mocker.patch(
|
||||
"lnbits.core.services.fiat_providers.time.time",
|
||||
return_value=int(timestamp) / 1000,
|
||||
check_revolut_signature(
|
||||
payload, sig, timestamp, secret, tolerance_seconds=100000000
|
||||
)
|
||||
check_revolut_signature(payload, sig, timestamp, secret)
|
||||
|
||||
check_revolut_signature(payload, sig, timestamp, secret)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -2,7 +2,6 @@ import json
|
||||
|
||||
import pytest
|
||||
|
||||
from lnbits.core.models.extensions import ExtensionPermission
|
||||
from lnbits.db import (
|
||||
dict_to_model,
|
||||
dict_to_submodel,
|
||||
@@ -104,19 +103,3 @@ async def test_helpers_dict_to_model_ignores_unknown_fields():
|
||||
model = dict_to_model({**test_dict, "ignored": "field"}, DbTestModel3)
|
||||
|
||||
assert model == test_data
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_helpers_dict_to_model_handles_list_any_fields():
|
||||
model = dict_to_model(
|
||||
{
|
||||
"id": "http.request",
|
||||
"policies": '[{"host": "https://api.example.com"}]',
|
||||
},
|
||||
ExtensionPermission,
|
||||
)
|
||||
|
||||
assert model == ExtensionPermission(
|
||||
id="http.request",
|
||||
policies=[{"host": "https://api.example.com"}],
|
||||
)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import json
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -14,25 +11,17 @@ from lnbits.core.crud import (
|
||||
)
|
||||
from lnbits.core.models.extensions import (
|
||||
Extension,
|
||||
ExtensionPermission,
|
||||
InstallableExtension,
|
||||
ReleasePaymentInfo,
|
||||
)
|
||||
from lnbits.core.services import extensions as extension_services
|
||||
from lnbits.core.services.extensions import (
|
||||
activate_extension,
|
||||
attach_wasm_invocation_runtime,
|
||||
deactivate_extension,
|
||||
finish_wasm_invocation,
|
||||
get_current_wasm_invocations,
|
||||
get_valid_extension,
|
||||
get_valid_extensions,
|
||||
install_extension,
|
||||
record_wasm_invocation_host_call,
|
||||
start_extension_background_work,
|
||||
start_wasm_invocation,
|
||||
stop_extension_background_work,
|
||||
stop_wasm_invocation,
|
||||
uninstall_extension,
|
||||
)
|
||||
from lnbits.settings import Settings
|
||||
@@ -154,59 +143,6 @@ async def test_install_extension_updates_existing_upgrade_and_preserves_payments
|
||||
start_mock.assert_awaited_once_with(ext_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_install_wasm_extension_requires_permissions_and_skips_background_work(
|
||||
tmp_path,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
ext_id = f"wasm_{uuid4().hex[:8]}"
|
||||
ext_info = make_installable_extension(ext_id)
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
start_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions.start_extension_background_work",
|
||||
mocker.AsyncMock(return_value=True),
|
||||
)
|
||||
|
||||
try:
|
||||
settings.lnbits_data_folder = str(tmp_path / "data")
|
||||
settings.lnbits_extensions_path = str(tmp_path / "code")
|
||||
_write_wasm_extension_archive(ext_info, _wasm_install_config(ext_id))
|
||||
|
||||
with pytest.raises(ValueError, match="requires permission approval"):
|
||||
await install_extension(ext_info, skip_download=True)
|
||||
|
||||
granted_permissions = [
|
||||
ExtensionPermission(
|
||||
id="http.request",
|
||||
policies=[{"host": "https://api.example.com"}],
|
||||
)
|
||||
]
|
||||
extension = await install_extension(
|
||||
ext_info,
|
||||
skip_download=True,
|
||||
granted_permissions=granted_permissions,
|
||||
)
|
||||
stored = await get_installed_extension(ext_id)
|
||||
finally:
|
||||
await delete_installed_extension(ext_id=ext_id)
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
|
||||
assert extension.code == ext_id
|
||||
assert extension.is_wasm is True
|
||||
assert stored is not None
|
||||
assert stored.permissions == [
|
||||
ExtensionPermission(
|
||||
id="http.request",
|
||||
description="Call example API.",
|
||||
policies=[{"host": "https://api.example.com"}],
|
||||
)
|
||||
]
|
||||
start_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_uninstall_activate_and_deactivate_extensions(
|
||||
tmp_path, settings: Settings, mocker: MockerFixture
|
||||
@@ -261,35 +197,6 @@ async def test_uninstall_activate_and_deactivate_extensions(
|
||||
assert start_mock.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_monitoring_marks_stale_once_and_cleans_periodically(
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
_reset_wasm_invocation_state()
|
||||
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
mark_stale_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
cleanup_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions.delete_old_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions._now",
|
||||
side_effect=[now, now + timedelta(minutes=1), now + timedelta(hours=2)],
|
||||
)
|
||||
|
||||
await extension_services.ensure_wasm_invocation_monitoring_ready()
|
||||
await extension_services.ensure_wasm_invocation_monitoring_ready()
|
||||
await extension_services.ensure_wasm_invocation_monitoring_ready()
|
||||
|
||||
mark_stale_mock.assert_awaited_once()
|
||||
assert cleanup_mock.await_count == 2
|
||||
cleanup_mock.assert_awaited_with(settings.lnbits_wasm_invocation_retention_days)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stop_extension_background_work_handles_missing_and_async_stops(
|
||||
mocker: MockerFixture,
|
||||
@@ -312,416 +219,6 @@ async def test_stop_extension_background_work_handles_missing_and_async_stops(
|
||||
assert called["stop"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_tracking_counts_and_stops(mocker: MockerFixture):
|
||||
_reset_wasm_invocation_state()
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.create_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
update_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions.update_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.get_wasm_invocation",
|
||||
mocker.AsyncMock(return_value=None),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.delete_old_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
|
||||
invocation = await start_wasm_invocation(
|
||||
extension_id="demoext",
|
||||
export_name="render",
|
||||
trigger_type="http",
|
||||
method="POST",
|
||||
path="/api/v1/ext/demoext/run",
|
||||
context={"origin": "https://example.com"},
|
||||
)
|
||||
store = SimpleNamespace(deadline=None)
|
||||
store.set_epoch_deadline = lambda deadline: setattr(store, "deadline", deadline)
|
||||
engine = SimpleNamespace(increments=0)
|
||||
|
||||
def increment_epoch():
|
||||
engine.increments += 1
|
||||
|
||||
engine.increment_epoch = increment_epoch
|
||||
|
||||
attach_wasm_invocation_runtime(invocation.id, engine=engine, store=store)
|
||||
record_wasm_invocation_host_call(invocation.id, "http.request")
|
||||
record_wasm_invocation_host_call(invocation.id, "storage.get")
|
||||
|
||||
assert await stop_wasm_invocation(invocation.id, reason="test stop") is True
|
||||
current = get_current_wasm_invocations()
|
||||
assert current[0].status == "stopping"
|
||||
assert store.deadline == 1
|
||||
assert engine.increments == 1
|
||||
|
||||
await finish_wasm_invocation(invocation.id, status="failed")
|
||||
assert update_mock.await_args is not None
|
||||
saved = update_mock.await_args.args[0]
|
||||
assert saved.status == "stopped"
|
||||
assert saved.stop_reason == "test stop"
|
||||
assert saved.host_call_count == 2
|
||||
assert saved.http_call_count == 1
|
||||
assert saved.storage_call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_context_and_error_message_are_sanitized(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
_reset_wasm_invocation_state()
|
||||
create_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions.create_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
update_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions.update_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.get_wasm_invocation",
|
||||
mocker.AsyncMock(return_value=None),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.delete_old_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
long_key = "k" * 80
|
||||
secret_hex = "a" * 64
|
||||
|
||||
invocation = await start_wasm_invocation(
|
||||
extension_id="demoext",
|
||||
export_name="render",
|
||||
context={
|
||||
long_key: "v" * 300,
|
||||
"attempt": 2,
|
||||
"enabled": True,
|
||||
"nested": {"raw": "value"},
|
||||
7: "ignored",
|
||||
},
|
||||
)
|
||||
await finish_wasm_invocation(
|
||||
invocation.id,
|
||||
status="failed",
|
||||
error_type="RuntimeError",
|
||||
error_message=f"api_key=supersecret Bearer abc.def {secret_hex} " + ("x" * 600),
|
||||
)
|
||||
|
||||
create_mock.assert_awaited_once()
|
||||
assert create_mock.await_args is not None
|
||||
created_invocation = create_mock.await_args.args[0]
|
||||
assert created_invocation.context == {
|
||||
"k" * 64: "v" * 256,
|
||||
"attempt": 2,
|
||||
"enabled": True,
|
||||
}
|
||||
update_mock.assert_awaited_once()
|
||||
assert update_mock.await_args is not None
|
||||
saved_invocation = update_mock.await_args.args[0]
|
||||
assert saved_invocation.error_message is not None
|
||||
assert "supersecret" not in saved_invocation.error_message
|
||||
assert "abc.def" not in saved_invocation.error_message
|
||||
assert secret_hex not in saved_invocation.error_message
|
||||
assert "api_key=[redacted]" in saved_invocation.error_message
|
||||
assert "Bearer [redacted]" in saved_invocation.error_message
|
||||
assert "[redacted-hex]" in saved_invocation.error_message
|
||||
|
||||
|
||||
def _reset_wasm_invocation_state():
|
||||
with extension_services._wasm_invocation_lock:
|
||||
extension_services._wasm_invocation_handles.clear()
|
||||
extension_services._wasm_invocations_marked_stale = False
|
||||
extension_services._wasm_invocations_last_cleanup_at = None
|
||||
|
||||
|
||||
def _write_wasm_extension_archive(
|
||||
ext_info: InstallableExtension,
|
||||
config: dict,
|
||||
) -> None:
|
||||
ext_info.zip_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
root = f"{ext_info.id}-{ext_info.version}"
|
||||
with zipfile.ZipFile(ext_info.zip_path, "w") as archive:
|
||||
archive.writestr(f"{root}/config.json", json.dumps(config))
|
||||
archive.writestr(f"{root}/{config['wasm']['module']}", b"\0asm")
|
||||
|
||||
|
||||
def _wasm_install_config(ext_id: str) -> dict:
|
||||
return {
|
||||
"id": ext_id,
|
||||
"name": f"WASM {ext_id}",
|
||||
"short_description": "WASM extension",
|
||||
"version": "1.0.0",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {"module": "extension.wasm"},
|
||||
"permissions": [
|
||||
{
|
||||
"id": "http.request",
|
||||
"description": "Call example API.",
|
||||
"policies": [{"host": "https://api.example.com"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_wasm_runtime_limits_merge_sparse_extension_overrides(settings: Settings):
|
||||
original_execution_ms = settings.wasm_runtime_max_execution_ms
|
||||
original_memory_bytes = settings.wasm_runtime_max_memory_bytes
|
||||
try:
|
||||
settings.wasm_runtime_max_execution_ms = 5_000
|
||||
settings.wasm_runtime_max_memory_bytes = 64 * 1024 * 1024
|
||||
extension = InstallableExtension(
|
||||
id="wasm_demo",
|
||||
name="WASM Demo",
|
||||
version="1.0.0",
|
||||
wasm_runtime_limits={
|
||||
"wasm_runtime_max_execution_ms": 20_000,
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
},
|
||||
)
|
||||
|
||||
limits = extension_services.resolve_wasm_runtime_limits(extension)
|
||||
|
||||
assert limits["wasm_runtime_max_execution_ms"] == 20_000
|
||||
assert limits["wasm_runtime_max_fuel"] == 0
|
||||
assert limits["wasm_runtime_max_memory_bytes"] == 64 * 1024 * 1024
|
||||
finally:
|
||||
settings.wasm_runtime_max_execution_ms = original_execution_ms
|
||||
settings.wasm_runtime_max_memory_bytes = original_memory_bytes
|
||||
|
||||
|
||||
def test_wasm_runtime_limit_override_validation():
|
||||
assert extension_services.validate_wasm_runtime_limit_overrides(
|
||||
{
|
||||
"wasm_runtime_max_execution_ms": "7000",
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
"wasm_runtime_max_memory_bytes": "",
|
||||
}
|
||||
) == {
|
||||
"wasm_runtime_max_execution_ms": 7000,
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown WASM runtime limit field"):
|
||||
extension_services.validate_wasm_runtime_limit_overrides({"unknown": 1})
|
||||
|
||||
with pytest.raises(ValueError, match="cannot be negative"):
|
||||
extension_services.validate_wasm_runtime_limit_overrides(
|
||||
{"wasm_runtime_max_execution_ms": -1}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="must be an integer"):
|
||||
extension_services.validate_wasm_runtime_limit_overrides(
|
||||
{"wasm_runtime_max_execution_ms": True}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="must be an integer"):
|
||||
extension_services.validate_wasm_runtime_limit_overrides(
|
||||
{"wasm_runtime_max_execution_ms": 1.5}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_wasm_extension_runtime_limits_saves_sparse_overrides(
|
||||
tmp_path,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
ext_id = "wasm_demo"
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
try:
|
||||
settings.lnbits_extensions_path = str(tmp_path)
|
||||
config_dir = tmp_path / "extensions" / ext_id
|
||||
config_dir.mkdir(parents=True)
|
||||
(config_dir / "config.json").write_text(
|
||||
'{"extension_type": "wasm"}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
installed_extension = InstallableExtension(
|
||||
id=ext_id,
|
||||
name="WASM Demo",
|
||||
version="1.0.0",
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.get_installed_extension",
|
||||
mocker.AsyncMock(return_value=installed_extension),
|
||||
)
|
||||
update_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions."
|
||||
"update_installed_extension_wasm_runtime_limits",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
|
||||
saved_limits = await extension_services.update_wasm_extension_runtime_limits(
|
||||
ext_id,
|
||||
{
|
||||
"wasm_runtime_max_execution_ms": "15000",
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
"wasm_runtime_max_memory_bytes": "",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
|
||||
assert saved_limits == {
|
||||
"wasm_runtime_max_execution_ms": 15000,
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
}
|
||||
update_mock.assert_awaited_once_with(ext_id=ext_id, limits=saved_limits)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_concurrency_limits(mocker: MockerFixture):
|
||||
_reset_wasm_invocation_state()
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.create_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.update_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.get_wasm_invocation",
|
||||
mocker.AsyncMock(return_value=None),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.delete_old_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
limits = extension_services.wasm_runtime_limit_defaults()
|
||||
limits.update(
|
||||
{
|
||||
"wasm_runtime_max_concurrent_invocations": 1,
|
||||
"wasm_runtime_max_concurrent_invocations_per_extension": 1,
|
||||
"wasm_runtime_max_concurrent_invocations_per_user": 1,
|
||||
}
|
||||
)
|
||||
|
||||
invocation = await start_wasm_invocation(
|
||||
extension_id="demoext",
|
||||
export_name="render",
|
||||
user_id="user-id",
|
||||
runtime_limits=limits,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="too many active invocations"):
|
||||
await start_wasm_invocation(
|
||||
extension_id="demoext",
|
||||
export_name="render",
|
||||
user_id="user-id",
|
||||
runtime_limits=limits,
|
||||
)
|
||||
|
||||
await finish_wasm_invocation(invocation.id, status="completed")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_host_call_limits(mocker: MockerFixture):
|
||||
_reset_wasm_invocation_state()
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.create_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.update_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.get_wasm_invocation",
|
||||
mocker.AsyncMock(return_value=None),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.delete_old_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
limits = extension_services.wasm_runtime_limit_defaults()
|
||||
limits["wasm_runtime_max_host_calls"] = 1
|
||||
|
||||
invocation = await start_wasm_invocation(
|
||||
extension_id="demoext",
|
||||
export_name="render",
|
||||
runtime_limits=limits,
|
||||
)
|
||||
record_wasm_invocation_host_call(invocation.id, "http.request")
|
||||
|
||||
with pytest.raises(ValueError, match="host call limit"):
|
||||
record_wasm_invocation_host_call(invocation.id, "storage.get")
|
||||
|
||||
await finish_wasm_invocation(invocation.id, status="failed")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_host_call_category_limits_can_be_disabled(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
_reset_wasm_invocation_state()
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.create_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.update_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.get_wasm_invocation",
|
||||
mocker.AsyncMock(return_value=None),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.delete_old_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
limits = extension_services.wasm_runtime_limit_defaults()
|
||||
limits["wasm_runtime_max_host_calls"] = 10
|
||||
limits["wasm_runtime_max_http_calls"] = 1
|
||||
|
||||
invocation = await start_wasm_invocation(
|
||||
extension_id="demoext",
|
||||
export_name="render",
|
||||
runtime_limits=limits,
|
||||
)
|
||||
record_wasm_invocation_host_call(invocation.id, "http.request")
|
||||
with pytest.raises(ValueError, match="http host call limit"):
|
||||
record_wasm_invocation_host_call(invocation.id, "extension.api.request")
|
||||
await finish_wasm_invocation(invocation.id, status="failed")
|
||||
|
||||
limits["wasm_runtime_max_host_calls"] = 0
|
||||
limits["wasm_runtime_max_http_calls"] = 0
|
||||
unlimited_invocation = await start_wasm_invocation(
|
||||
extension_id="demoext",
|
||||
export_name="render",
|
||||
runtime_limits=limits,
|
||||
)
|
||||
for _ in range(5):
|
||||
record_wasm_invocation_host_call(unlimited_invocation.id, "http.request")
|
||||
|
||||
await finish_wasm_invocation(unlimited_invocation.id, status="completed")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_start_extension_background_work_handles_missing_and_sync_starts(
|
||||
mocker: MockerFixture,
|
||||
|
||||
@@ -27,7 +27,6 @@ from lnbits.core.services.payments import (
|
||||
check_pending_payments,
|
||||
check_time_limit_between_transactions,
|
||||
check_transaction_status,
|
||||
check_wallet_daily_withdraw_limit,
|
||||
check_wallet_limits,
|
||||
create_payment_request,
|
||||
get_payments_daily_stats,
|
||||
@@ -111,36 +110,6 @@ async def test_update_pending_payment_and_bulk_pending_updates(mocker: MockerFix
|
||||
assert bulk_statuses == {PaymentState.FAILED, PaymentState.SUCCESS}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_pending_payment_marks_expired_incoming_invoice_failed(
|
||||
app,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
wallet = await _create_wallet()
|
||||
checking_id = await _create_payment(
|
||||
wallet,
|
||||
expiry=datetime.now(timezone.utc) - timedelta(seconds=1),
|
||||
labels=["test"],
|
||||
)
|
||||
payment = await get_payment(checking_id)
|
||||
check_status_mock = mocker.patch(
|
||||
"lnbits.core.services.payments.check_payment_status",
|
||||
mocker.AsyncMock(
|
||||
side_effect=AssertionError("expired invoices should not be checked")
|
||||
),
|
||||
)
|
||||
|
||||
updated_payment = await update_pending_payment(payment)
|
||||
|
||||
assert updated_payment.status == PaymentState.FAILED
|
||||
assert updated_payment.labels == ["test", "expired"]
|
||||
check_status_mock.assert_not_awaited()
|
||||
|
||||
stored_payment = await get_payment(checking_id)
|
||||
assert stored_payment.status == PaymentState.FAILED
|
||||
assert stored_payment.labels == ["test", "expired"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_check_pending_payments_skips_voidwallet_and_updates_recent_items(
|
||||
mocker: MockerFixture,
|
||||
@@ -279,23 +248,6 @@ async def test_check_wallet_limits_and_time_limit(
|
||||
settings.lnbits_wallet_limit_secs_between_trans = original_limit
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_check_wallet_daily_limit_counts_all_daily_payments(settings: Settings):
|
||||
wallet = await _create_wallet()
|
||||
await _create_payment(wallet, amount_msat=-2_000, status=PaymentState.SUCCESS)
|
||||
await _create_payment(wallet, amount_msat=-3_000, status=PaymentState.SUCCESS)
|
||||
|
||||
original_limit = settings.lnbits_wallet_limit_daily_max_withdraw
|
||||
try:
|
||||
settings.lnbits_wallet_limit_daily_max_withdraw = 5
|
||||
with pytest.raises(
|
||||
ValueError, match="Daily withdrawal limit of 5 sats reached."
|
||||
):
|
||||
await check_wallet_daily_withdraw_limit(wallet.id, 1_000)
|
||||
finally:
|
||||
settings.lnbits_wallet_limit_daily_max_withdraw = original_limit
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_calculate_fiat_amounts_handles_conversion_and_errors(
|
||||
mocker: MockerFixture,
|
||||
@@ -481,8 +433,6 @@ async def _create_payment(
|
||||
payment_hash: str | None = None,
|
||||
fee: int = 0,
|
||||
time: datetime | None = None,
|
||||
expiry: datetime | None = None,
|
||||
labels: list[str] | None = None,
|
||||
) -> str:
|
||||
checking_id = checking_id or f"checking_{uuid4().hex[:8]}"
|
||||
payment_hash = payment_hash or uuid4().hex
|
||||
@@ -494,9 +444,7 @@ async def _create_payment(
|
||||
bolt11=f"bolt11-{checking_id}",
|
||||
amount_msat=amount_msat,
|
||||
memo="memo",
|
||||
expiry=expiry,
|
||||
fee=fee,
|
||||
labels=labels,
|
||||
),
|
||||
status=status,
|
||||
)
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.core.wasm_ext.api.models import ExtensionApiRequest
|
||||
from lnbits.core.wasm_ext.client import extensions as extension_client
|
||||
from lnbits.settings import Settings
|
||||
|
||||
|
||||
def test_wasm_extension_api_path_validation():
|
||||
assert (
|
||||
extension_client._extension_api_path("/api/v1/payments?limit=1")
|
||||
== "/api/v1/payments?limit=1"
|
||||
)
|
||||
|
||||
for path, message in [
|
||||
("https://example.com/api/v1/payments", "relative"),
|
||||
("/wallet", "start with '/api/'"),
|
||||
("/api/v1/payments#frag", "fragment"),
|
||||
("/api/v1/../admin", "traverse"),
|
||||
("/api/v1/%2e%2e/admin", "traverse"),
|
||||
("/api//v1/payments", "invalid"),
|
||||
]:
|
||||
with pytest.raises(PermissionError, match=message):
|
||||
extension_client._extension_api_path(path)
|
||||
|
||||
|
||||
def test_wasm_extension_api_target_and_access_validation():
|
||||
assert extension_client._target_extension_id(" target_ext ") == "target_ext"
|
||||
for extension_id in ["", "../admin", "bad.ext"]:
|
||||
with pytest.raises(PermissionError, match="invalid target"):
|
||||
extension_client._target_extension_id(extension_id)
|
||||
|
||||
assert extension_client._target_extension_access(["target"], "target") == {"read"}
|
||||
assert extension_client._target_extension_access(
|
||||
[{"id": "target", "access": ["read", "write", "ignored"]}],
|
||||
"target",
|
||||
) == {"read", "write"}
|
||||
|
||||
read_request = ExtensionApiRequest(
|
||||
extension_id="target",
|
||||
method="GET",
|
||||
path="/api/v1/demo",
|
||||
body=None,
|
||||
)
|
||||
write_request = ExtensionApiRequest(
|
||||
extension_id="target",
|
||||
method="POST",
|
||||
path="/api/v1/demo",
|
||||
body="{}",
|
||||
)
|
||||
extension_client._require_method_access(
|
||||
"caller",
|
||||
"target",
|
||||
{"read"},
|
||||
read_request,
|
||||
)
|
||||
with pytest.raises(PermissionError, match="cannot write"):
|
||||
extension_client._require_method_access(
|
||||
"caller",
|
||||
"target",
|
||||
{"read"},
|
||||
write_request,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_extension_api_request_enforces_auth_policy_and_user_enablement(
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
settings.host = "127.0.0.1"
|
||||
settings.port = 5000
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.extensions.get_installed_extension",
|
||||
mocker.AsyncMock(return_value=SimpleNamespace(active=True)),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.extensions.get_user_active_extensions_ids",
|
||||
mocker.AsyncMock(return_value=["target"]),
|
||||
)
|
||||
client = _FakeAsyncClient(
|
||||
_FakeStreamResponse(
|
||||
status_code=202,
|
||||
headers={"set-cookie": "secret", "x-result": "ok"},
|
||||
chunks=[b'{"accepted":true}'],
|
||||
)
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.extensions.httpx.AsyncClient",
|
||||
client.factory,
|
||||
)
|
||||
|
||||
response = await extension_client.send_extension_api_request(
|
||||
"caller",
|
||||
[{"id": "target", "access": ["write"]}],
|
||||
"user-id",
|
||||
"access-token",
|
||||
ExtensionApiRequest(
|
||||
extension_id="target",
|
||||
method="POST",
|
||||
path="/api/v1/run?value=1",
|
||||
body="{}",
|
||||
),
|
||||
timeout_ms=750,
|
||||
max_response_bytes=100,
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert response.body == '{"accepted":true}'
|
||||
assert response.headers == {"x-result": "ok"}
|
||||
assert client.kwargs["follow_redirects"] is False
|
||||
assert client.kwargs["trust_env"] is False
|
||||
assert client.kwargs["timeout"] == 0.75
|
||||
assert (
|
||||
client.stream_kwargs["url"] == "http://127.0.0.1:5000/target/api/v1/run?value=1"
|
||||
)
|
||||
assert client.stream_kwargs["headers"] == {"Authorization": "Bearer access-token"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_extension_api_request_rejects_missing_auth_and_disabled_targets(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
request = ExtensionApiRequest(
|
||||
extension_id="target",
|
||||
method="GET",
|
||||
path="/api/v1/run",
|
||||
body=None,
|
||||
)
|
||||
with pytest.raises(PermissionError, match="authentication"):
|
||||
await extension_client.send_extension_api_request(
|
||||
"caller",
|
||||
["target"],
|
||||
None,
|
||||
"access-token",
|
||||
request,
|
||||
)
|
||||
with pytest.raises(PermissionError, match="access token"):
|
||||
await extension_client.send_extension_api_request(
|
||||
"caller",
|
||||
["target"],
|
||||
"user-id",
|
||||
None,
|
||||
request,
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.extensions.get_installed_extension",
|
||||
mocker.AsyncMock(return_value=SimpleNamespace(active=False)),
|
||||
)
|
||||
with pytest.raises(PermissionError, match="not installed or enabled"):
|
||||
await extension_client.send_extension_api_request(
|
||||
"caller",
|
||||
["target"],
|
||||
"user-id",
|
||||
"access-token",
|
||||
request,
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.extensions.get_installed_extension",
|
||||
mocker.AsyncMock(return_value=SimpleNamespace(active=True)),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.extensions.get_user_active_extensions_ids",
|
||||
mocker.AsyncMock(return_value=[]),
|
||||
)
|
||||
with pytest.raises(PermissionError, match="not active for this user"):
|
||||
await extension_client.send_extension_api_request(
|
||||
"caller",
|
||||
["target"],
|
||||
"user-id",
|
||||
"access-token",
|
||||
request,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_extension_api_request_rejects_oversized_body_and_response(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.extensions.get_installed_extension",
|
||||
mocker.AsyncMock(return_value=SimpleNamespace(active=True)),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.extensions.get_user_active_extensions_ids",
|
||||
mocker.AsyncMock(return_value=["target"]),
|
||||
)
|
||||
with pytest.raises(ValueError, match="body is too large"):
|
||||
await extension_client.send_extension_api_request(
|
||||
"caller",
|
||||
[{"id": "target", "access": ["write"]}],
|
||||
"user-id",
|
||||
"access-token",
|
||||
ExtensionApiRequest.construct(
|
||||
extension_id="target",
|
||||
method="POST",
|
||||
path="/api/v1/run",
|
||||
body="x" * 65_537,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="response is too large"):
|
||||
await extension_client._read_limited_response(
|
||||
cast(httpx.Response, _FakeStreamResponse(chunks=[b"12345", b"67890"])),
|
||||
max_response_bytes=8,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_extension_api_request_hides_transport_errors(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.extensions.get_installed_extension",
|
||||
mocker.AsyncMock(return_value=SimpleNamespace(active=True)),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.extensions.get_user_active_extensions_ids",
|
||||
mocker.AsyncMock(return_value=["target"]),
|
||||
)
|
||||
client = _FakeAsyncClient(_FakeStreamError())
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.extensions.httpx.AsyncClient",
|
||||
client.factory,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Extension API request failed"):
|
||||
await extension_client.send_extension_api_request(
|
||||
"caller",
|
||||
["target"],
|
||||
"user-id",
|
||||
"access-token",
|
||||
ExtensionApiRequest(
|
||||
extension_id="target",
|
||||
method="GET",
|
||||
path="/api/v1/run",
|
||||
body=None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
self.kwargs = {}
|
||||
self.stream_kwargs = {}
|
||||
|
||||
def factory(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return False
|
||||
|
||||
def stream(self, method, url, **kwargs):
|
||||
self.stream_kwargs = {"method": method, "url": url, **kwargs}
|
||||
return self.response
|
||||
|
||||
|
||||
class _FakeStreamResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status_code: int = 200,
|
||||
headers: dict[str, str] | None = None,
|
||||
chunks: list[bytes] | None = None,
|
||||
):
|
||||
self.status_code = status_code
|
||||
self.headers = headers or {}
|
||||
self.encoding = "utf-8"
|
||||
self._chunks = chunks or []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return False
|
||||
|
||||
async def aiter_bytes(self):
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
class _FakeStreamError:
|
||||
async def __aenter__(self):
|
||||
raise httpx.RequestError(
|
||||
"network failed",
|
||||
request=httpx.Request("GET", "http://127.0.0.1"),
|
||||
)
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return False
|
||||
@@ -1,295 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.core.db import core_app_extra
|
||||
from lnbits.core.models.extensions import ExtensionPermission
|
||||
from lnbits.core.wasm_ext.wasm.config import parse_wasm_extension_config
|
||||
from lnbits.core.wasm_ext.wasm.events import (
|
||||
_payment_extension_id,
|
||||
_payment_source_id,
|
||||
_wasm_public_invoice_source_tables_from_permissions,
|
||||
dispatch_wasm_invoice_paid,
|
||||
)
|
||||
from lnbits.core.wasm_ext.wasm.loader import WasmExtension
|
||||
from lnbits.helpers import sha256s
|
||||
|
||||
|
||||
def test_wasm_invoice_paid_helpers_extract_extension_and_source_tables():
|
||||
payment = SimpleNamespace(
|
||||
extension="",
|
||||
extra={"tag": "demoext", "source_id": "row-1"},
|
||||
tag="fallback",
|
||||
)
|
||||
permissions = [
|
||||
ExtensionPermission(
|
||||
id="wallet.create_invoice_public",
|
||||
policies=[
|
||||
{"table": "tip_jars", "wallet_field": "wallet_id"},
|
||||
{"table": "", "wallet_field": "wallet_id"},
|
||||
],
|
||||
),
|
||||
ExtensionPermission(id="http.request"),
|
||||
]
|
||||
|
||||
assert _payment_extension_id(payment) == "demoext"
|
||||
assert _payment_source_id(payment) == "row-1"
|
||||
assert _wasm_public_invoice_source_tables_from_permissions(permissions) == [
|
||||
"tip_jars"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatch_wasm_invoice_paid_invokes_registered_event_export_with_owner(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
ext_id = "demo_event_ext"
|
||||
extension = _wasm_extension(ext_id)
|
||||
registry = core_app_extra.wasm_extension_registry
|
||||
registry.register(extension)
|
||||
installed_extension = SimpleNamespace(
|
||||
permissions=[
|
||||
ExtensionPermission(
|
||||
id="wallet.create_invoice_public",
|
||||
policies=[{"table": "tip_jars", "wallet_field": "wallet_id"}],
|
||||
)
|
||||
]
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.get_installed_extension",
|
||||
mocker.AsyncMock(return_value=installed_extension),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.get_wallet",
|
||||
mocker.AsyncMock(return_value=None),
|
||||
)
|
||||
storage_mock = mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.storage_get_row_owner_id",
|
||||
mocker.AsyncMock(return_value="owner-1"),
|
||||
)
|
||||
invoke_mock = mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.invoke_wasm_extension_export",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
payment = _payment(ext_id)
|
||||
|
||||
try:
|
||||
await dispatch_wasm_invoice_paid(payment)
|
||||
finally:
|
||||
registry._extensions.pop(ext_id, None)
|
||||
|
||||
storage_mock.assert_awaited_once_with(ext_id, "tip_jars", "row-1")
|
||||
invoke_mock.assert_awaited_once()
|
||||
assert invoke_mock.await_args is not None
|
||||
args = invoke_mock.await_args.args
|
||||
kwargs = invoke_mock.await_args.kwargs
|
||||
assert args[0] == ext_id
|
||||
assert args[1] == "on_invoice_paid"
|
||||
assert args[2]["paymentHash"] == "payment-hash"
|
||||
assert args[2]["payment"] == {"id": "payment-row"}
|
||||
assert kwargs["context"] == "event"
|
||||
assert kwargs["owner_id"] == "owner-1"
|
||||
assert kwargs["trigger_type"] == "event"
|
||||
assert kwargs["event_type"] == "invoice_paid"
|
||||
assert kwargs["wallet_id"] == "wallet-1"
|
||||
assert kwargs["payment_hash"] == "payment-hash"
|
||||
assert kwargs["checking_id"] == "checking-id"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatch_wasm_invoice_paid_skips_invalid_event_export_visibility(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
ext_id = "demo_public_event_ext"
|
||||
extension = _wasm_extension(ext_id, visibility="public")
|
||||
registry = core_app_extra.wasm_extension_registry
|
||||
registry.register(extension)
|
||||
invoke_mock = mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.invoke_wasm_extension_export",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.get_wallet",
|
||||
mocker.AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
try:
|
||||
await dispatch_wasm_invoice_paid(_payment(ext_id))
|
||||
finally:
|
||||
registry._extensions.pop(ext_id, None)
|
||||
|
||||
invoke_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatch_wasm_invoice_paid_invokes_wallet_watch_grant(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
ext_id = "demo_wallet_watch_ext"
|
||||
extension = _wasm_extension(ext_id)
|
||||
registry = core_app_extra.wasm_extension_registry
|
||||
registry.register(extension)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.get_wallet",
|
||||
mocker.AsyncMock(return_value=SimpleNamespace(id="wallet-1", user="user-1")),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.get_user_extensions",
|
||||
mocker.AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(
|
||||
active=True,
|
||||
extension=ext_id,
|
||||
permissions={
|
||||
"wallet.payments.watch": [
|
||||
{
|
||||
"id": "grant-1",
|
||||
"wallet_id": "wallet-1",
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.get_installed_extension",
|
||||
mocker.AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
active=True,
|
||||
permissions=[ExtensionPermission(id="wallet.payments.watch")],
|
||||
)
|
||||
),
|
||||
)
|
||||
invoke_mock = mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.invoke_wasm_extension_export",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
|
||||
try:
|
||||
await dispatch_wasm_invoice_paid(_payment(""))
|
||||
finally:
|
||||
registry._extensions.pop(ext_id, None)
|
||||
|
||||
invoke_mock.assert_awaited_once()
|
||||
assert invoke_mock.await_args is not None
|
||||
args = invoke_mock.await_args.args
|
||||
kwargs = invoke_mock.await_args.kwargs
|
||||
assert args[0] == ext_id
|
||||
assert args[1] == "on_invoice_paid"
|
||||
assert args[2]["paymentHash"] == "payment-hash"
|
||||
assert kwargs["context"] == "event"
|
||||
assert kwargs["owner_id"] == sha256s("user-1")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatch_wasm_invoice_paid_dedupes_tagged_wallet_watch_grant(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
ext_id = "demo_dedupe_event_ext"
|
||||
extension = _wasm_extension(ext_id)
|
||||
registry = core_app_extra.wasm_extension_registry
|
||||
registry.register(extension)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.get_wallet",
|
||||
mocker.AsyncMock(return_value=SimpleNamespace(id="wallet-1", user="user-1")),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.get_user_extensions",
|
||||
mocker.AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(
|
||||
active=True,
|
||||
extension=ext_id,
|
||||
permissions={
|
||||
"wallet.payments.watch": [
|
||||
{
|
||||
"id": "grant-1",
|
||||
"wallet_id": "wallet-1",
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.get_installed_extension",
|
||||
mocker.AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
active=True,
|
||||
permissions=[ExtensionPermission(id="wallet.payments.watch")],
|
||||
)
|
||||
),
|
||||
)
|
||||
invoke_mock = mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.invoke_wasm_extension_export",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
|
||||
try:
|
||||
await dispatch_wasm_invoice_paid(_payment(ext_id, extra={}))
|
||||
finally:
|
||||
registry._extensions.pop(ext_id, None)
|
||||
|
||||
invoke_mock.assert_awaited_once()
|
||||
assert invoke_mock.await_args is not None
|
||||
assert invoke_mock.await_args.kwargs["owner_id"] == sha256s("user-1")
|
||||
|
||||
|
||||
def _wasm_extension(ext_id: str, *, visibility: str = "event") -> WasmExtension:
|
||||
config = parse_wasm_extension_config(
|
||||
ext_id,
|
||||
{
|
||||
"id": ext_id,
|
||||
"name": "Demo event extension",
|
||||
"short_description": "Demo",
|
||||
"version": "1.0.0",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {
|
||||
"module": "extension.wasm",
|
||||
"exports": [
|
||||
{
|
||||
"name": "on_invoice_paid",
|
||||
"visibility": visibility,
|
||||
}
|
||||
],
|
||||
},
|
||||
"events": {"onInvoicePaid": "on_invoice_paid"},
|
||||
},
|
||||
)
|
||||
root_path = Path(__file__).resolve().parent / ext_id
|
||||
return WasmExtension(
|
||||
id=ext_id,
|
||||
name=config.name,
|
||||
version=config.version,
|
||||
root_path=root_path,
|
||||
module_path=root_path / "extension.wasm",
|
||||
wit_path=None,
|
||||
world=config.wasm.world,
|
||||
exports=config.wasm.exports,
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
def _payment(ext_id: str, *, extra: dict | None = None) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
extension=ext_id,
|
||||
extra={"source_id": "row-1"} if extra is None else extra,
|
||||
tag=None,
|
||||
wallet_id="wallet-1",
|
||||
payment_hash="payment-hash",
|
||||
checking_id="checking-id",
|
||||
amount=1000,
|
||||
fee=0,
|
||||
bolt11="lnbc1",
|
||||
memo="memo",
|
||||
pending=False,
|
||||
status="success",
|
||||
json=lambda: json.dumps({"id": "payment-row"}),
|
||||
)
|
||||
@@ -1,57 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_wasm_frontend_assets_are_registered_in_component_bundle():
|
||||
package_json = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
|
||||
components = package_json["bundle"]["components"]
|
||||
|
||||
assert "js/wasm-extension-component.js" in components
|
||||
assert "js/components/lnbits-extension-permissions.js" in components
|
||||
assert "js/components/admin/lnbits-admin-wasm-runtime.js" in components
|
||||
assert "js/components/admin/lnbits-admin-wasm-limit-config.js" in components
|
||||
|
||||
|
||||
def test_wasm_frontend_bridge_restricts_api_routes_and_payment_actions():
|
||||
bridge = (ROOT / "lnbits/static/js/wasm-extension-component.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert "allowedApiRoute(method, path)" in bridge
|
||||
assert "url.origin !== window.location.origin" in bridge
|
||||
assert "Extension API route is not allowed." in bridge
|
||||
assert "message.action === 'payment.subscribe'" in bridge
|
||||
assert "message.action === 'payment.unsubscribe'" in bridge
|
||||
assert "message.action === 'ui.scan_qr'" in bridge
|
||||
|
||||
|
||||
def test_wasm_extension_install_ui_requests_permissions_before_install_paths():
|
||||
extensions_page = (ROOT / "lnbits/static/js/pages/extensions.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
wasm_bulk_update_skip_message = (
|
||||
"Skipping ${ext.id}; this extension update requires permission approval."
|
||||
)
|
||||
|
||||
assert "await this.resolveExtensionPermissionGrant(release)" in extensions_page
|
||||
assert "permissions: grantedPermissions" in extensions_page
|
||||
assert "release.extension_type === 'wasm'" in extensions_page
|
||||
assert "this.selectedExtension?.isWasm === true" in extensions_page
|
||||
assert wasm_bulk_update_skip_message in extensions_page
|
||||
|
||||
|
||||
def test_wasm_admin_frontend_calls_runtime_limit_and_invocation_endpoints():
|
||||
runtime = (
|
||||
ROOT / "lnbits/static/js/components/admin/lnbits-admin-wasm-runtime.js"
|
||||
).read_text(encoding="utf-8")
|
||||
limits = (
|
||||
ROOT / "lnbits/static/js/components/admin/lnbits-admin-wasm-limit-config.js"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "/api/v1/extension/wasm/invocations/current" in runtime
|
||||
assert "/api/v1/extension/wasm/invocations?" in runtime
|
||||
assert "/api/v1/extension/wasm/invocations/stats?" in runtime
|
||||
assert "/api/v1/extension/wasm/runtime-limits/extensions" in limits
|
||||
assert "/api/v1/extension/wasm/runtime-limits/${encodeURIComponent(" in limits
|
||||
@@ -1,348 +0,0 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.core.models.extensions import ExtensionPermission
|
||||
from lnbits.core.wasm_ext.api.host import ExtensionHostAPI
|
||||
from lnbits.core.wasm_ext.api.models import (
|
||||
CreateInvoicePublicRequest,
|
||||
EmptyRequest,
|
||||
PayInvoiceRequest,
|
||||
StorageGetRequest,
|
||||
WalletBalanceRequest,
|
||||
)
|
||||
from lnbits.exceptions import PaymentError
|
||||
from lnbits.helpers import sha256s
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_api_filters_public_storage_fields(mocker: MockerFixture):
|
||||
storage_mock = mocker.patch(
|
||||
"lnbits.core.wasm_ext.api.host.storage_get_public_row",
|
||||
mocker.AsyncMock(
|
||||
return_value={
|
||||
"id": "tip-1",
|
||||
"title": "Tip jar",
|
||||
"wallet_id": "secret-wallet",
|
||||
}
|
||||
),
|
||||
)
|
||||
api = ExtensionHostAPI(
|
||||
"demoext",
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="ext.storage.read_public",
|
||||
policies=[{"table_name": "tips", "public_fields": ["id", "title"]}],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
response = await api.storage_get_public(StorageGetRequest(table="tips", id="tip-1"))
|
||||
|
||||
assert json.loads(response.data_json or "{}") == {
|
||||
"id": "tip-1",
|
||||
"title": "Tip jar",
|
||||
}
|
||||
storage_mock.assert_awaited_once_with("demoext", "tips", "tip-1")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_api_storage_requires_owner_context_and_uses_user_hash(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
api_without_owner = ExtensionHostAPI(
|
||||
"demoext",
|
||||
["ext.storage.read"],
|
||||
context="event",
|
||||
)
|
||||
with pytest.raises(PermissionError, match="owner context"):
|
||||
await api_without_owner.storage_get(StorageGetRequest(table="notes", id="1"))
|
||||
|
||||
storage_mock = mocker.patch(
|
||||
"lnbits.core.wasm_ext.api.host.storage_get_row",
|
||||
mocker.AsyncMock(return_value={"id": "1", "title": "Note"}),
|
||||
)
|
||||
api = ExtensionHostAPI("demoext", ["ext.storage.read"], user_id="user-1")
|
||||
|
||||
response = await api.storage_get(StorageGetRequest(table="notes", id="1"))
|
||||
|
||||
assert json.loads(response.data_json or "{}") == {"id": "1", "title": "Note"}
|
||||
storage_mock.assert_awaited_once_with(
|
||||
"demoext",
|
||||
"notes",
|
||||
"1",
|
||||
sha256s("user-1"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_api_wallet_methods_require_permissions_and_user_wallets(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
api_without_permission = ExtensionHostAPI("demoext", [], user_id="user-1")
|
||||
with pytest.raises(PermissionError, match="wallet.list"):
|
||||
await api_without_permission.wallet_list_user_wallets(EmptyRequest())
|
||||
|
||||
wallet = SimpleNamespace(id="wallet-1", name="Wallet", currency="USD")
|
||||
mocker.patch(
|
||||
"lnbits.core.crud.wallets.get_wallets",
|
||||
mocker.AsyncMock(return_value=[wallet]),
|
||||
)
|
||||
api = ExtensionHostAPI("demoext", ["wallet.list"], user_id="user-1")
|
||||
|
||||
response = await api.wallet_list_user_wallets(EmptyRequest())
|
||||
|
||||
assert response.wallets[0].id == "wallet-1"
|
||||
assert response.wallets[0].name == "Wallet"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_api_wallet_balance_rejects_other_users_wallets(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
mocker.patch(
|
||||
"lnbits.core.crud.wallets.get_wallet",
|
||||
mocker.AsyncMock(return_value=SimpleNamespace(id="wallet-1", user="other")),
|
||||
)
|
||||
api = ExtensionHostAPI(
|
||||
"demoext",
|
||||
["wallet.balance.read"],
|
||||
user_id="user-1",
|
||||
)
|
||||
|
||||
with pytest.raises(PermissionError, match="not allowed"):
|
||||
await api.wallet_balance(WalletBalanceRequest(wallet_id="wallet-1"))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_api_wallet_balance_returns_safe_summary(mocker: MockerFixture):
|
||||
wallet = SimpleNamespace(
|
||||
id="wallet-1",
|
||||
user="user-1",
|
||||
name="Wallet",
|
||||
currency="USD",
|
||||
balance_msat=10_000,
|
||||
balance=10,
|
||||
withdrawable_balance=8_000,
|
||||
can_send_payments=True,
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.crud.wallets.get_wallet",
|
||||
mocker.AsyncMock(return_value=wallet),
|
||||
)
|
||||
api = ExtensionHostAPI(
|
||||
"demoext",
|
||||
["wallet.balance.read"],
|
||||
user_id="user-1",
|
||||
)
|
||||
|
||||
response = await api.wallet_balance(WalletBalanceRequest(wallet_id="wallet-1"))
|
||||
|
||||
assert response.wallet_id == "wallet-1"
|
||||
assert response.balance_msat == 10_000
|
||||
assert response.withdrawable_msat == 8_000
|
||||
assert response.fee_reserve_msat == 2_000
|
||||
assert response.can_send_payments is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_api_pay_invoice_checks_wallet_owner_and_returns_payment_errors(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
wallet = SimpleNamespace(id="wallet-1", user="user-1")
|
||||
mocker.patch(
|
||||
"lnbits.core.crud.wallets.get_wallet",
|
||||
mocker.AsyncMock(return_value=wallet),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.payments.pay_invoice",
|
||||
mocker.AsyncMock(side_effect=PaymentError("insufficient balance")),
|
||||
)
|
||||
api = ExtensionHostAPI("demoext", ["wallet.pay_invoice"], user_id="user-1")
|
||||
|
||||
response = await api.wallet_pay_invoice(
|
||||
PayInvoiceRequest(
|
||||
wallet_id="wallet-1",
|
||||
payment_request="lnbc1demo",
|
||||
max_sat=None,
|
||||
description="Demo",
|
||||
extra={"source": "test"},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.ok is False
|
||||
assert response.error == "insufficient balance"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_api_background_pay_invoice_requires_background_permission(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
wallet = SimpleNamespace(id="wallet-1", user="user-1")
|
||||
mocker.patch(
|
||||
"lnbits.core.crud.wallets.get_wallet",
|
||||
mocker.AsyncMock(return_value=wallet),
|
||||
)
|
||||
api = ExtensionHostAPI("demoext", [], context="event")
|
||||
|
||||
response = await api.wallet_pay_invoice(
|
||||
PayInvoiceRequest(
|
||||
wallet_id="wallet-1",
|
||||
payment_request="lnbc1demo",
|
||||
max_sat=None,
|
||||
description="Demo",
|
||||
extra={},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.ok is False
|
||||
assert "wallet.pay_invoice_background" in (response.error or "")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_api_background_pay_invoice_uses_background_grant_metadata(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
wallet = SimpleNamespace(id="wallet-1", user="user-1")
|
||||
mocker.patch(
|
||||
"lnbits.core.crud.wallets.get_wallet",
|
||||
mocker.AsyncMock(return_value=wallet),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.api.host.invoice_amount_msat",
|
||||
return_value=21_000,
|
||||
)
|
||||
background_extra_mock = mocker.patch(
|
||||
"lnbits.core.wasm_ext.api.host.background_payment_extra",
|
||||
mocker.AsyncMock(
|
||||
return_value={
|
||||
"tag": "demoext",
|
||||
"background_payment": True,
|
||||
"background_permission": "wallet.pay_invoice_background",
|
||||
"background_wallet_id": "wallet-1",
|
||||
}
|
||||
),
|
||||
)
|
||||
payment = SimpleNamespace(
|
||||
checking_id="checking-id",
|
||||
payment_hash="payment-hash",
|
||||
status="success",
|
||||
amount=-21_000,
|
||||
fee=0,
|
||||
pending=False,
|
||||
success=True,
|
||||
)
|
||||
pay_mock = mocker.patch(
|
||||
"lnbits.core.services.payments.pay_invoice",
|
||||
mocker.AsyncMock(return_value=payment),
|
||||
)
|
||||
api = ExtensionHostAPI(
|
||||
"demoext",
|
||||
["wallet.pay_invoice_background"],
|
||||
context="event",
|
||||
)
|
||||
|
||||
response = await api.wallet_pay_invoice(
|
||||
PayInvoiceRequest(
|
||||
wallet_id="wallet-1",
|
||||
payment_request="lnbc1demo",
|
||||
max_sat=None,
|
||||
description="Demo",
|
||||
extra={"source": "test"},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.ok is True
|
||||
background_extra_mock.assert_awaited_once()
|
||||
pay_mock.assert_awaited_once()
|
||||
assert pay_mock.await_args is not None
|
||||
assert pay_mock.await_args.kwargs["extra"] == {
|
||||
"source": "test",
|
||||
"tag": "demoext",
|
||||
"background_payment": True,
|
||||
"background_permission": "wallet.pay_invoice_background",
|
||||
"background_wallet_id": "wallet-1",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_api_public_invoice_uses_granted_source_policy(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
storage_mock = mocker.patch(
|
||||
"lnbits.core.wasm_ext.api.host.storage_get_public_row",
|
||||
mocker.AsyncMock(return_value={"id": "tip-1", "wallet_id": "wallet-1"}),
|
||||
)
|
||||
payment = SimpleNamespace(
|
||||
payment_hash="hash",
|
||||
payment_request="lnbc1demo",
|
||||
bolt11="lnbc1fallback",
|
||||
checking_id="checking-id",
|
||||
)
|
||||
create_mock = mocker.patch(
|
||||
"lnbits.core.services.payments.create_payment_request",
|
||||
mocker.AsyncMock(return_value=payment),
|
||||
)
|
||||
api = ExtensionHostAPI(
|
||||
"demoext",
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="wallet.create_invoice_public",
|
||||
policies=[{"table": "tips", "wallet_field": "wallet_id"}],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
response = await api.wallet_create_invoice_public(
|
||||
CreateInvoicePublicRequest(
|
||||
source_id="tip-1",
|
||||
amount=21,
|
||||
currency="sat",
|
||||
memo="Tip",
|
||||
extra={"note": "thanks"},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.payment_hash == "hash"
|
||||
storage_mock.assert_awaited_once_with("demoext", "tips", "tip-1")
|
||||
create_mock.assert_awaited_once()
|
||||
assert create_mock.await_args is not None
|
||||
wallet_id, invoice = create_mock.await_args.args
|
||||
assert wallet_id == "wallet-1"
|
||||
assert invoice.extra == {
|
||||
"tag": "demoext",
|
||||
"source_id": "tip-1",
|
||||
"extra_demoext": {"note": "thanks"},
|
||||
}
|
||||
assert invoice.extension == "demoext"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_api_public_invoice_rejects_missing_source_wallet(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.api.host.storage_get_public_row",
|
||||
mocker.AsyncMock(return_value={"id": "tip-1"}),
|
||||
)
|
||||
api = ExtensionHostAPI(
|
||||
"demoext",
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="wallet.create_invoice_public",
|
||||
policies=[{"table": "tips", "wallet_field": "wallet_id"}],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(PermissionError, match="no valid wallet"):
|
||||
await api.wallet_create_invoice_public(
|
||||
CreateInvoicePublicRequest(
|
||||
source_id="tip-1",
|
||||
amount=21,
|
||||
currency="sat",
|
||||
memo="",
|
||||
)
|
||||
)
|
||||
@@ -1,214 +0,0 @@
|
||||
import ipaddress
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.core.wasm_ext.api.models import HttpRequest
|
||||
from lnbits.core.wasm_ext.client import http as wasm_http
|
||||
|
||||
|
||||
def test_wasm_http_origin_normalization_and_url_rejections():
|
||||
assert (
|
||||
wasm_http._request_origin("https://EXAMPLE.com/path") == "https://example.com"
|
||||
)
|
||||
assert (
|
||||
wasm_http._request_origin("https://example.com:8443/path")
|
||||
== "https://example.com:8443"
|
||||
)
|
||||
|
||||
for url, message in [
|
||||
("http://example.com", "https URLs"),
|
||||
("https://user:pass@example.com", "credentials"),
|
||||
("https:///missing-host", "hostname"),
|
||||
("https://example.com:bad", "invalid port"),
|
||||
]:
|
||||
with pytest.raises(PermissionError, match=message):
|
||||
wasm_http._request_origin(url)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_http_rejects_internal_hosts(mocker: MockerFixture):
|
||||
with pytest.raises(PermissionError, match="localhost"):
|
||||
await wasm_http._reject_internal_host("https://localhost/api")
|
||||
|
||||
with pytest.raises(PermissionError, match="internal network"):
|
||||
await wasm_http._reject_internal_host("https://127.0.0.1/api")
|
||||
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.http._resolve_host",
|
||||
mocker.AsyncMock(return_value=[ipaddress.ip_address("10.0.0.1")]),
|
||||
)
|
||||
with pytest.raises(PermissionError, match="internal network"):
|
||||
await wasm_http._reject_internal_host("https://api.example.com")
|
||||
|
||||
|
||||
def test_wasm_http_strips_forbidden_headers_and_normalizes_timeout():
|
||||
assert wasm_http._request_headers(
|
||||
{
|
||||
" Host ": "evil.example",
|
||||
"Content-Length": "10",
|
||||
"X-Trace": "ok",
|
||||
"Cookie": "session=secret",
|
||||
}
|
||||
) == {"X-Trace": "ok"}
|
||||
assert wasm_http._response_headers(
|
||||
{
|
||||
"content-length": "10",
|
||||
"set-cookie": "session=secret",
|
||||
"x-safe": "ok",
|
||||
}
|
||||
) == {"x-safe": "ok"}
|
||||
assert wasm_http._timeout_seconds(None, 10.0) == 10.0
|
||||
assert wasm_http._timeout_seconds(2500, 10.0) == 2.5
|
||||
assert wasm_http._timeout_seconds(0, 10.0) is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_http_request_enforces_policies_and_response_bounds(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.http._reject_internal_host",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
client = _FakeAsyncClient(
|
||||
_FakeStreamResponse(
|
||||
status_code=201,
|
||||
headers={
|
||||
"content-type": "application/json",
|
||||
"set-cookie": "session=secret",
|
||||
},
|
||||
chunks=[b'{"ok":true}'],
|
||||
)
|
||||
)
|
||||
mocker.patch("lnbits.core.wasm_ext.client.http.httpx.AsyncClient", client.factory)
|
||||
|
||||
response = await wasm_http.send_extension_http_request(
|
||||
"demoext",
|
||||
[{"host": "https://api.example.com"}],
|
||||
HttpRequest(
|
||||
method="POST",
|
||||
url="https://api.example.com/path",
|
||||
headers={"Host": "evil.example", "X-Test": "yes"},
|
||||
body="{}",
|
||||
),
|
||||
timeout_ms=500,
|
||||
max_response_bytes=100,
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.body == '{"ok":true}'
|
||||
assert response.headers == {"content-type": "application/json"}
|
||||
assert client.kwargs["follow_redirects"] is False
|
||||
assert client.kwargs["trust_env"] is False
|
||||
assert client.kwargs["timeout"] == 0.5
|
||||
assert client.stream_kwargs["headers"] == {"X-Test": "yes"}
|
||||
|
||||
with pytest.raises(PermissionError, match="not allowed"):
|
||||
await wasm_http.send_extension_http_request(
|
||||
"demoext",
|
||||
[{"host": "https://api.example.com"}],
|
||||
HttpRequest(url="https://other.example.com/path", body=None),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_http_request_rejects_oversized_bodies_and_responses(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.http._reject_internal_host",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
with pytest.raises(ValueError, match="body is too large"):
|
||||
await wasm_http.send_extension_http_request(
|
||||
"demoext",
|
||||
[{"host": "https://api.example.com"}],
|
||||
HttpRequest.construct(
|
||||
method="GET",
|
||||
url="https://api.example.com/path",
|
||||
headers={},
|
||||
body="x" * 65_537,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="response is too large"):
|
||||
await wasm_http._read_limited_response(
|
||||
cast(httpx.Response, _FakeStreamResponse(chunks=[b"12345", b"67890"])),
|
||||
max_response_bytes=8,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_http_request_hides_transport_errors(mocker: MockerFixture):
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.client.http._reject_internal_host",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
client = _FakeAsyncClient(_FakeStreamError())
|
||||
mocker.patch("lnbits.core.wasm_ext.client.http.httpx.AsyncClient", client.factory)
|
||||
|
||||
with pytest.raises(ValueError, match="HTTP request failed"):
|
||||
await wasm_http.send_extension_http_request(
|
||||
"demoext",
|
||||
[{"host": "https://api.example.com"}],
|
||||
HttpRequest(url="https://api.example.com/path", body=None),
|
||||
)
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
self.kwargs = {}
|
||||
self.stream_kwargs = {}
|
||||
|
||||
def factory(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return False
|
||||
|
||||
def stream(self, method, url, **kwargs):
|
||||
self.stream_kwargs = {"method": method, "url": url, **kwargs}
|
||||
return self.response
|
||||
|
||||
|
||||
class _FakeStreamResponse:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status_code: int = 200,
|
||||
headers: dict[str, str] | None = None,
|
||||
chunks: list[bytes] | None = None,
|
||||
):
|
||||
self.status_code = status_code
|
||||
self.headers = headers or {}
|
||||
self.encoding = "utf-8"
|
||||
self._chunks = chunks or []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return False
|
||||
|
||||
async def aiter_bytes(self):
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
class _FakeStreamError:
|
||||
async def __aenter__(self):
|
||||
raise httpx.RequestError(
|
||||
"network failed",
|
||||
request=httpx.Request("GET", "https://api.example.com"),
|
||||
)
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return False
|
||||
@@ -1,223 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from lnbits.core.models.extensions import ExtensionPermission
|
||||
from lnbits.core.models.misc import WasmExtensionRegistry
|
||||
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
|
||||
from lnbits.core.wasm_ext.wasm.config import parse_wasm_extension_config
|
||||
from lnbits.core.wasm_ext.wasm.loader import WasmExtension, load_wasm_extension
|
||||
from lnbits.settings import Settings
|
||||
from tests.helpers import make_installable_extension
|
||||
|
||||
|
||||
def test_load_wasm_extension_rejects_missing_config_id(
|
||||
tmp_path: Path, settings: Settings
|
||||
):
|
||||
ext_id = "demoext"
|
||||
_write_wasm_extension(settings, tmp_path, ext_id, config_id=None)
|
||||
|
||||
with pytest.raises(ValueError, match="config must define id"):
|
||||
load_wasm_extension(ext_id)
|
||||
|
||||
|
||||
def test_load_wasm_extension_rejects_mismatched_config_id(
|
||||
tmp_path: Path, settings: Settings
|
||||
):
|
||||
ext_id = "demoext"
|
||||
_write_wasm_extension(settings, tmp_path, ext_id, config_id="otherext")
|
||||
|
||||
with pytest.raises(ValueError, match="id mismatch"):
|
||||
load_wasm_extension(ext_id)
|
||||
|
||||
|
||||
def test_load_wasm_extension_uses_canonical_extension_id(
|
||||
tmp_path: Path, settings: Settings
|
||||
):
|
||||
ext_id = "demoext"
|
||||
_write_wasm_extension(settings, tmp_path, ext_id, config_id=ext_id)
|
||||
|
||||
extension = load_wasm_extension(ext_id)
|
||||
|
||||
assert extension.id == ext_id
|
||||
|
||||
|
||||
def test_wasm_extension_config_ignores_unknown_fields():
|
||||
config = _wasm_config("demoext")
|
||||
config["unexpected"] = True
|
||||
|
||||
parsed = parse_wasm_extension_config("demoext", config)
|
||||
|
||||
assert not hasattr(parsed, "unexpected")
|
||||
|
||||
|
||||
def test_wasm_extension_config_rejects_coerced_scalar_types():
|
||||
config = _wasm_config("demoext")
|
||||
config["wasm"] = {"module": 123}
|
||||
|
||||
with pytest.raises(ValueError, match="str type expected"):
|
||||
parse_wasm_extension_config("demoext", config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_update",
|
||||
[
|
||||
{"wasm": {"module": "extension.wasm", "host_api": "custom.HostAPI"}},
|
||||
{
|
||||
"wasm": {
|
||||
"module": "extension.wasm",
|
||||
"resource_limits": {"max_response_bytes": 1024},
|
||||
}
|
||||
},
|
||||
{"build": {"source": "dev", "command": "npm run build"}},
|
||||
],
|
||||
)
|
||||
def test_wasm_extension_config_ignores_removed_extension_control_fields(
|
||||
config_update: dict[str, Any],
|
||||
):
|
||||
config = _wasm_config("demoext")
|
||||
config.update(config_update)
|
||||
|
||||
parsed = parse_wasm_extension_config("demoext", config)
|
||||
|
||||
assert parsed.wasm.module == "extension.wasm"
|
||||
assert not hasattr(parsed.wasm, "host_api")
|
||||
assert not hasattr(parsed.wasm, "resource_limits")
|
||||
assert not hasattr(parsed, "build")
|
||||
|
||||
|
||||
def test_wasm_extension_config_accepts_supported_optional_sections():
|
||||
config = _wasm_config("demoext")
|
||||
config.update(
|
||||
{
|
||||
"tile": "static/icon.png",
|
||||
"min_lnbits_version": "1.0.0",
|
||||
"max_lnbits_version": "2.0.0",
|
||||
"wasm": {
|
||||
"module": "wasm/module.wasm",
|
||||
"wit": "wasm/lnbits-extension.wit",
|
||||
"world": "lnbits-extension",
|
||||
"exports": [
|
||||
{"name": "render", "visibility": "public"},
|
||||
{"name": "on_invoice_paid", "visibility": "event"},
|
||||
],
|
||||
},
|
||||
"events": {"onInvoicePaid": "on_invoice_paid"},
|
||||
"ui": {"entrypoint": "static/index.html", "sandbox": True},
|
||||
"sdk": {"frontend_js": "static/lnbits-extension-sdk.js"},
|
||||
"ui_routes": [
|
||||
{
|
||||
"path": "/demo/{item_id}",
|
||||
"entrypoint": "static/index.html",
|
||||
"auth": "user",
|
||||
"path_params": {"item_id": "str"},
|
||||
}
|
||||
],
|
||||
"api_routes": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/demo/{item_id}",
|
||||
"export": "render",
|
||||
"auth": "public",
|
||||
"path_params": {"item_id": "str"},
|
||||
}
|
||||
],
|
||||
"permissions": [{"id": "utils.basic", "description": "Basic utils"}],
|
||||
}
|
||||
)
|
||||
|
||||
parsed = parse_wasm_extension_config("demoext", config)
|
||||
|
||||
assert parsed.events.on_invoice_paid == "on_invoice_paid"
|
||||
assert parsed.wasm.world == "lnbits-extension"
|
||||
|
||||
|
||||
def test_wasm_extension_config_ignores_unknown_permission_fields():
|
||||
config = _wasm_config("demoext")
|
||||
config["permissions"] = [
|
||||
{"id": "utils.basic", "label": "Basic utilities", "unknown": True}
|
||||
]
|
||||
|
||||
parsed = parse_wasm_extension_config("demoext", config)
|
||||
|
||||
assert parsed.permissions == [ExtensionPermission(id="utils.basic")]
|
||||
|
||||
|
||||
def test_install_time_permission_validation_rejects_config_id_mismatch():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = {
|
||||
"id": "otherext",
|
||||
"extension_type": "wasm",
|
||||
"permissions": [{"id": "utils.basic"}],
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="id mismatch"):
|
||||
validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[ExtensionPermission(id="utils.basic")],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
|
||||
def test_wasm_extension_registry_rejects_same_id_from_different_root(tmp_path: Path):
|
||||
registry = WasmExtensionRegistry()
|
||||
first = _wasm_extension("demoext", tmp_path / "one")
|
||||
second_same_root = _wasm_extension("demoext", tmp_path / "one")
|
||||
second_different_root = _wasm_extension("demoext", tmp_path / "two")
|
||||
|
||||
registry.register(first)
|
||||
registry.register(second_same_root)
|
||||
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
registry.register(second_different_root)
|
||||
|
||||
|
||||
def _write_wasm_extension(
|
||||
settings: Settings,
|
||||
tmp_path: Path,
|
||||
ext_id: str,
|
||||
*,
|
||||
config_id: str | None,
|
||||
) -> None:
|
||||
settings.lnbits_extensions_path = str(tmp_path)
|
||||
ext_dir = tmp_path / "extensions" / ext_id
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "extension.wasm").write_bytes(b"\0asm")
|
||||
config = {
|
||||
"name": "Demo",
|
||||
"short_description": "Demo extension",
|
||||
"version": "1.0.0",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {"module": "extension.wasm"},
|
||||
}
|
||||
if config_id is not None:
|
||||
config["id"] = config_id
|
||||
(ext_dir / "config.json").write_text(json.dumps(config), encoding="utf-8")
|
||||
|
||||
|
||||
def _wasm_extension(ext_id: str, root_path: Path) -> WasmExtension:
|
||||
config = parse_wasm_extension_config(ext_id, _wasm_config(ext_id))
|
||||
return WasmExtension(
|
||||
id=ext_id,
|
||||
name=ext_id,
|
||||
version="1.0.0",
|
||||
root_path=root_path,
|
||||
module_path=root_path / "extension.wasm",
|
||||
wit_path=None,
|
||||
world="",
|
||||
exports=[],
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
def _wasm_config(ext_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"id": ext_id,
|
||||
"name": ext_id,
|
||||
"short_description": "Demo extension",
|
||||
"version": "1.0.0",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {"module": "extension.wasm"},
|
||||
}
|
||||
@@ -1,409 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.core.models.extensions import (
|
||||
ExtensionBackgroundPaymentDestinationPolicy,
|
||||
ExtensionPermission,
|
||||
)
|
||||
from lnbits.core.views.extension_api import (
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION,
|
||||
_background_destination_policy_covers,
|
||||
_check_background_payment_permission,
|
||||
_check_wallet_payments_watch_permission,
|
||||
_find_background_payment_grant,
|
||||
_find_wallet_payments_watch_grant,
|
||||
_remove_user_permission_grant,
|
||||
_safe_user_extension_permissions,
|
||||
_user_permission_grant_id_for_wallet,
|
||||
)
|
||||
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
|
||||
from lnbits.core.wasm_ext.wasm.events import _wasm_invoice_paid_owner_id
|
||||
from lnbits.core.wasm_ext.wasm.invoke import _active_installed_extension
|
||||
from tests.helpers import make_installable_extension
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_rejects_broader_policy_grant():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "http.request",
|
||||
"policies": [{"host": "https://api.example.com"}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="broader policies"):
|
||||
validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="http.request",
|
||||
policies=[
|
||||
{"host": "https://api.example.com"},
|
||||
{"host": "https://evil.example.com"},
|
||||
],
|
||||
)
|
||||
],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_stores_narrower_policy_grant():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "ext.storage.read_public",
|
||||
"description": "Read public storage.",
|
||||
"policies": [
|
||||
{
|
||||
"table_name": "tip_jars",
|
||||
"public_fields": ["id", "title", "description"],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
permissions = validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="ext.storage.read_public",
|
||||
policies=[
|
||||
{
|
||||
"table_name": "tip_jars",
|
||||
"public_fields": ["id", "title"],
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
assert permissions == [
|
||||
ExtensionPermission(
|
||||
id="ext.storage.read_public",
|
||||
description="Read public storage.",
|
||||
policies=[
|
||||
{
|
||||
"table_name": "tip_jars",
|
||||
"public_fields": ["id", "title"],
|
||||
}
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_rejects_broader_extension_api_access():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "extension.api.request",
|
||||
"policies": [{"id": "targetext", "access": ["read"]}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="broader policies"):
|
||||
validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="extension.api.request",
|
||||
policies=[{"id": "targetext", "access": ["read", "write"]}],
|
||||
)
|
||||
],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_allows_empty_grant():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "wallet.create_invoice_public",
|
||||
"policies": [{"table": "tip_jars", "wallet_field": "wallet_id"}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert validate_wasm_extension_permissions(ext_info, [], extension_config) == []
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_rejects_unrequested_permission_grant():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config("demoext", [{"id": "utils.basic"}])
|
||||
|
||||
with pytest.raises(ValueError, match="unrequested permissions"):
|
||||
validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[
|
||||
ExtensionPermission(id="utils.basic"),
|
||||
ExtensionPermission(id="wallet.list"),
|
||||
],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_allows_wallet_payments_watch_permission():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[{"id": "wallet.payments.watch"}],
|
||||
)
|
||||
|
||||
assert validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[ExtensionPermission(id="wallet.payments.watch")],
|
||||
extension_config,
|
||||
) == [ExtensionPermission(id="wallet.payments.watch")]
|
||||
|
||||
|
||||
def test_background_payment_grant_lookup_and_policy_coverage():
|
||||
permissions = {
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
|
||||
{
|
||||
"id": "grant-1",
|
||||
"wallet_id": "wallet-1",
|
||||
"enabled": True,
|
||||
"max_amount": 5000,
|
||||
"destination_policy": "external_allowed",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
grant = _find_background_payment_grant(permissions, "wallet-1")
|
||||
|
||||
assert grant
|
||||
assert grant.max_amount == 5000
|
||||
assert _background_destination_policy_covers(
|
||||
grant.destination_policy,
|
||||
ExtensionBackgroundPaymentDestinationPolicy.OWN_WALLETS_ONLY,
|
||||
)
|
||||
assert _background_destination_policy_covers(
|
||||
grant.destination_policy,
|
||||
ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED,
|
||||
)
|
||||
assert not _background_destination_policy_covers(
|
||||
ExtensionBackgroundPaymentDestinationPolicy.OWN_WALLETS_ONLY,
|
||||
ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED,
|
||||
)
|
||||
|
||||
|
||||
def test_wallet_payments_watch_grant_lookup_ignores_disabled_grant():
|
||||
permissions = {
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION: [
|
||||
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": False}
|
||||
]
|
||||
}
|
||||
|
||||
grant = _find_wallet_payments_watch_grant(permissions, "wallet-1")
|
||||
|
||||
assert grant
|
||||
assert grant.enabled is False
|
||||
|
||||
|
||||
def test_safe_user_extension_permissions_keeps_only_grants_with_ids():
|
||||
permissions = {
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION: [
|
||||
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": True},
|
||||
{"wallet_id": "wallet-2", "enabled": True},
|
||||
"broken",
|
||||
],
|
||||
"broken": "not-a-list",
|
||||
}
|
||||
|
||||
safe_permissions = _safe_user_extension_permissions(permissions)
|
||||
|
||||
assert safe_permissions == {
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION: [
|
||||
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": True}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_user_permission_grant_id_for_wallet_returns_existing_grant_id():
|
||||
permissions = {
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
|
||||
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": True}
|
||||
]
|
||||
}
|
||||
|
||||
grant_id = _user_permission_grant_id_for_wallet(
|
||||
permissions, WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, "wallet-1"
|
||||
)
|
||||
|
||||
assert grant_id == "grant-1"
|
||||
|
||||
|
||||
def test_remove_user_permission_grant_removes_only_matching_grant_id():
|
||||
permissions = {
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
|
||||
{"id": "grant-1", "wallet_id": "wallet-1", "enabled": True},
|
||||
{"id": "grant-2", "wallet_id": "wallet-2", "enabled": True},
|
||||
],
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION: [{"id": "grant-3", "wallet_id": "wallet-1"}],
|
||||
}
|
||||
|
||||
updated_permissions = _remove_user_permission_grant(permissions, "grant-1")
|
||||
|
||||
assert updated_permissions == {
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
|
||||
{"id": "grant-2", "wallet_id": "wallet-2", "enabled": True}
|
||||
],
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION: [{"id": "grant-3", "wallet_id": "wallet-1"}],
|
||||
}
|
||||
|
||||
|
||||
def test_remove_user_permission_grant_drops_empty_permission():
|
||||
permissions = {
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION: [{"id": "grant-1", "wallet_id": "wallet-1"}]
|
||||
}
|
||||
|
||||
updated_permissions = _remove_user_permission_grant(permissions, "grant-1")
|
||||
|
||||
assert updated_permissions == {}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_background_payment_check_reports_approved_grant(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
permissions = {
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
|
||||
{
|
||||
"id": "grant-1",
|
||||
"wallet_id": "wallet-1",
|
||||
"enabled": True,
|
||||
"max_amount": 5000,
|
||||
"destination_policy": "external_allowed",
|
||||
}
|
||||
]
|
||||
}
|
||||
mocker.patch(
|
||||
"lnbits.core.views.extension_api.get_wallet",
|
||||
mocker.AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
user="user-1",
|
||||
is_lightning_shared_wallet=False,
|
||||
can_send_payments=True,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
result = await _check_background_payment_permission(
|
||||
"user-1",
|
||||
permissions,
|
||||
{
|
||||
"wallet_id": "wallet-1",
|
||||
"max_amount": 1000,
|
||||
"destination_policy": "own_wallets_only",
|
||||
},
|
||||
)
|
||||
|
||||
assert result.id == WALLET_PAY_INVOICE_BACKGROUND_PERMISSION
|
||||
assert result.approved is True
|
||||
assert result.grant["id"] == "grant-1"
|
||||
assert result.grant["max_amount"] == 5000
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wallet_payment_watch_check_returns_requested_unapproved_grant(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
mocker.patch(
|
||||
"lnbits.core.views.extension_api.get_wallet",
|
||||
mocker.AsyncMock(return_value=SimpleNamespace(user="user-1")),
|
||||
)
|
||||
|
||||
result = await _check_wallet_payments_watch_permission(
|
||||
"user-1",
|
||||
{},
|
||||
{"wallet_id": "wallet-1"},
|
||||
)
|
||||
|
||||
assert result.id == WALLET_PAYMENTS_WATCH_PERMISSION
|
||||
assert result.approved is False
|
||||
assert result.grant["wallet_id"] == "wallet-1"
|
||||
assert result.grant["enabled"] is True
|
||||
assert isinstance(result.grant["id"], str)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invoice_paid_owner_lookup_uses_stored_granted_policies(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
extension = SimpleNamespace(
|
||||
id="demoext",
|
||||
config=_wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "wallet.create_invoice_public",
|
||||
"policies": [
|
||||
{"table": "requested_table", "wallet_field": "wallet_id"}
|
||||
],
|
||||
}
|
||||
],
|
||||
),
|
||||
)
|
||||
payment = SimpleNamespace(extra={"source_id": "source-1"})
|
||||
installed_extension = SimpleNamespace(
|
||||
permissions=[
|
||||
ExtensionPermission(
|
||||
id="wallet.create_invoice_public",
|
||||
policies=[{"table": "granted_table", "wallet_field": "wallet_id"}],
|
||||
)
|
||||
]
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.get_installed_extension",
|
||||
mocker.AsyncMock(return_value=installed_extension),
|
||||
)
|
||||
storage_mock = mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.storage_get_row_owner_id",
|
||||
mocker.AsyncMock(return_value="owner-1"),
|
||||
)
|
||||
|
||||
owner_id = await _wasm_invoice_paid_owner_id(extension, payment)
|
||||
|
||||
assert owner_id == "owner-1"
|
||||
storage_mock.assert_awaited_once_with("demoext", "granted_table", "source-1")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_requires_installed_active_extension(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
extension = SimpleNamespace(id="demoext")
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.invoke.get_installed_extension",
|
||||
mocker.AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
with pytest.raises(PermissionError, match="deactivated"):
|
||||
await _active_installed_extension(cast(Any, extension))
|
||||
|
||||
|
||||
def _wasm_config(ext_id: str, permissions: list[dict]) -> dict:
|
||||
return {
|
||||
"id": ext_id,
|
||||
"name": ext_id,
|
||||
"short_description": "Demo extension",
|
||||
"version": "1.0.0",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {"module": "extension.wasm"},
|
||||
"permissions": permissions,
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from lnbits.core.wasm_ext.routes.api import (
|
||||
WasmRequestBodyTooLargeError,
|
||||
_read_api_payload,
|
||||
_read_json_object_with_size,
|
||||
_wasm_extension_api_export,
|
||||
)
|
||||
from lnbits.core.wasm_ext.routes.assets import (
|
||||
_reject_html_like_wasm_static_asset,
|
||||
_wasm_extension_core_asset_response,
|
||||
)
|
||||
from lnbits.core.wasm_ext.routes.security import (
|
||||
consume_wasm_extension_frame_token,
|
||||
wasm_extension_frame_csp,
|
||||
wasm_extension_frame_url,
|
||||
)
|
||||
from lnbits.core.wasm_ext.routes.ui import (
|
||||
_match_wasm_extension_ui_route,
|
||||
_wasm_extension_bridge_api_routes,
|
||||
_wasm_extension_entrypoint,
|
||||
)
|
||||
from lnbits.core.wasm_ext.wasm.config import parse_wasm_extension_config
|
||||
from lnbits.core.wasm_ext.wasm.loader import WasmExtension
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_json_reader_rejects_large_content_length_without_reading():
|
||||
request = _FakeRequest([b"{}"], content_length="11")
|
||||
|
||||
with pytest.raises(WasmRequestBodyTooLargeError, match="11 bytes"):
|
||||
await _read_json_object_with_size(cast(Request, request), max_body_bytes=10)
|
||||
|
||||
assert request.stream_started is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_json_reader_rejects_large_stream_without_content_length():
|
||||
request = _FakeRequest([b'{"value":"', b"x" * 20, b'"}'])
|
||||
|
||||
with pytest.raises(WasmRequestBodyTooLargeError):
|
||||
await _read_json_object_with_size(cast(Request, request), max_body_bytes=16)
|
||||
|
||||
assert request.stream_started is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_api_payload_records_actual_body_bytes():
|
||||
body = b'{"amount":21}'
|
||||
request = _FakeRequest(
|
||||
[body],
|
||||
path_params={"invoice_id": "abc"},
|
||||
query_params={"include_paid": "true"},
|
||||
)
|
||||
|
||||
payload = await _read_api_payload(
|
||||
cast(Request, request),
|
||||
{"invoice_id": "invoiceId"},
|
||||
max_body_bytes=100,
|
||||
)
|
||||
|
||||
assert payload.data == {
|
||||
"invoiceId": "abc",
|
||||
"includePaid": "true",
|
||||
"amount": 21,
|
||||
}
|
||||
assert payload.request_bytes == len(body)
|
||||
|
||||
|
||||
def test_wasm_api_export_visibility_is_enforced(tmp_path: Path):
|
||||
extension = _wasm_extension(tmp_path)
|
||||
|
||||
assert _wasm_extension_api_export(extension, "render") == "render"
|
||||
assert _wasm_extension_api_export(extension, "private_render") == "private_render"
|
||||
with pytest.raises(PermissionError, match="not callable over HTTP"):
|
||||
_wasm_extension_api_export(extension, "on_invoice_paid")
|
||||
with pytest.raises(KeyError, match="has no export"):
|
||||
_wasm_extension_api_export(extension, "missing")
|
||||
|
||||
|
||||
def test_wasm_ui_entrypoint_rejects_escape_static_and_non_html(tmp_path: Path):
|
||||
extension = _wasm_extension(tmp_path)
|
||||
(tmp_path / "index.html").write_text("<html></html>", encoding="utf-8")
|
||||
(tmp_path / "index.txt").write_text("text", encoding="utf-8")
|
||||
(tmp_path / "static").mkdir()
|
||||
(tmp_path / "static" / "index.html").write_text("<html></html>", encoding="utf-8")
|
||||
|
||||
assert (
|
||||
_wasm_extension_entrypoint(extension, "index.html")
|
||||
== (tmp_path / "index.html").resolve()
|
||||
)
|
||||
with pytest.raises(ValueError, match="escapes extension root"):
|
||||
_wasm_extension_entrypoint(extension, "../outside.html")
|
||||
with pytest.raises(ValueError, match="must not be inside the static"):
|
||||
_wasm_extension_entrypoint(extension, "static/index.html")
|
||||
with pytest.raises(ValueError, match="must be an HTML file"):
|
||||
_wasm_extension_entrypoint(extension, "index.txt")
|
||||
|
||||
|
||||
def test_wasm_frame_token_is_one_time_and_user_bound(tmp_path: Path):
|
||||
extension = _wasm_extension(tmp_path)
|
||||
frame_path = "/ext-frame/demoext/0"
|
||||
frame_url = wasm_extension_frame_url(extension, frame_path, "user-1")
|
||||
token = frame_url.split("frame_token=", 1)[1]
|
||||
|
||||
with pytest.raises(HTTPException) as wrong_user:
|
||||
consume_wasm_extension_frame_token(
|
||||
_request_with_query(token),
|
||||
extension,
|
||||
frame_path,
|
||||
"user-2",
|
||||
)
|
||||
assert wrong_user.value.status_code == 404
|
||||
|
||||
consume_wasm_extension_frame_token(
|
||||
_request_with_query(token),
|
||||
extension,
|
||||
frame_path,
|
||||
"user-1",
|
||||
)
|
||||
with pytest.raises(HTTPException) as reused:
|
||||
consume_wasm_extension_frame_token(
|
||||
_request_with_query(token),
|
||||
extension,
|
||||
frame_path,
|
||||
"user-1",
|
||||
)
|
||||
assert reused.value.status_code == 404
|
||||
|
||||
|
||||
def test_wasm_frame_csp_is_locked_to_extension_assets(tmp_path: Path):
|
||||
csp = wasm_extension_frame_csp(
|
||||
_request_with_query("token"),
|
||||
_wasm_extension(tmp_path),
|
||||
)
|
||||
|
||||
assert "sandbox allow-scripts" in csp
|
||||
assert "default-src 'none'" in csp
|
||||
assert "connect-src 'none'" in csp
|
||||
assert "frame-ancestors 'self'" in csp
|
||||
assert "http://testserver/ext-assets/demoext/" in csp
|
||||
|
||||
|
||||
def test_wasm_ui_route_matching_and_bridge_public_api_filtering(tmp_path: Path):
|
||||
extension = _wasm_extension(tmp_path)
|
||||
|
||||
matched = _match_wasm_extension_ui_route(extension, "/ext/demo/abc")
|
||||
public_routes = _wasm_extension_bridge_api_routes(extension, public=True)
|
||||
private_routes = _wasm_extension_bridge_api_routes(extension, public=False)
|
||||
|
||||
assert matched["auth"] == "user"
|
||||
assert matched["route_params"] == {"item_id": "abc"}
|
||||
assert public_routes == [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/ext/demoext/public/{item_id}",
|
||||
"pattern": "^/api/v1/ext/demoext/public/[^/]+$",
|
||||
}
|
||||
]
|
||||
assert {route["path"] for route in private_routes} == {
|
||||
"/api/v1/ext/demoext/public/{item_id}",
|
||||
"/api/v1/ext/demoext/private/{item_id}",
|
||||
}
|
||||
|
||||
|
||||
def test_wasm_static_core_assets_and_html_like_text_assets_are_guarded(tmp_path: Path):
|
||||
response = _wasm_extension_core_asset_response("_lnbits/material-icons.css")
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
|
||||
for path in ["_lnbits/../bundle.min.css", "_lnbits/missing.css"]:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_wasm_extension_core_asset_response(path)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
script_path = tmp_path / "app.js"
|
||||
script_path.write_text("<script>alert(1)</script>", encoding="utf-8")
|
||||
with pytest.raises(HTTPException) as html_like:
|
||||
_reject_html_like_wasm_static_asset(script_path)
|
||||
assert html_like.value.status_code == 404
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
method = "POST"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chunks: list[bytes],
|
||||
*,
|
||||
content_length: str | None = None,
|
||||
path_params: dict[str, str] | None = None,
|
||||
query_params: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self._chunks = chunks
|
||||
self.headers: dict[str, str] = {}
|
||||
if content_length is not None:
|
||||
self.headers["content-length"] = content_length
|
||||
self.path_params = path_params or {}
|
||||
self.query_params = query_params or {}
|
||||
self.stream_started = False
|
||||
|
||||
async def stream(self) -> AsyncIterator[bytes]:
|
||||
self.stream_started = True
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
def _wasm_extension(root_path: Path) -> WasmExtension:
|
||||
config = parse_wasm_extension_config(
|
||||
"demoext",
|
||||
{
|
||||
"id": "demoext",
|
||||
"name": "Demo",
|
||||
"short_description": "Demo extension",
|
||||
"version": "1.0.0",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {
|
||||
"module": "extension.wasm",
|
||||
"exports": [
|
||||
{"name": "render", "visibility": "public"},
|
||||
{"name": "private_render", "visibility": "authenticated"},
|
||||
{"name": "on_invoice_paid", "visibility": "event"},
|
||||
],
|
||||
},
|
||||
"ui_routes": [
|
||||
{
|
||||
"path": "/demo/{item_id}",
|
||||
"entrypoint": "index.html",
|
||||
"auth": "user",
|
||||
}
|
||||
],
|
||||
"api_routes": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/public/{item_id}",
|
||||
"export": "render",
|
||||
"auth": "public",
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/private/{item_id}",
|
||||
"export": "private_render",
|
||||
"auth": "user",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
return WasmExtension(
|
||||
id="demoext",
|
||||
name="Demo",
|
||||
version="1.0.0",
|
||||
root_path=root_path,
|
||||
module_path=root_path / "extension.wasm",
|
||||
wit_path=None,
|
||||
world="",
|
||||
exports=config.wasm.exports,
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
def _request_with_query(token: str) -> Request:
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"scheme": "http",
|
||||
"server": ("testserver", 80),
|
||||
"path": "/ext-frame/demoext/0",
|
||||
"query_string": f"frame_token={token}".encode(),
|
||||
"headers": [],
|
||||
}
|
||||
)
|
||||
@@ -1,409 +0,0 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.core.crud import extensions as extension_crud
|
||||
from lnbits.core.crud import update_migration_version
|
||||
from lnbits.core.db import db as core_db
|
||||
from lnbits.core.migrations import (
|
||||
m010_create_installed_extensions_table,
|
||||
m046_add_permissions_to_installed_extensions,
|
||||
m047_create_wasm_invocations_table,
|
||||
m048_add_wasm_runtime_limits_to_installed_extensions,
|
||||
)
|
||||
from lnbits.core.models.extensions import ExtensionPermission, WasmInvocation
|
||||
from lnbits.core.wasm_ext.storage import crud as storage_crud
|
||||
from lnbits.core.wasm_ext.storage.crud import (
|
||||
OWNER_ID_FIELD,
|
||||
migrate_wasm_extension_database,
|
||||
storage_delete_row,
|
||||
storage_get_paginated_rows,
|
||||
storage_get_public_row,
|
||||
storage_get_row,
|
||||
storage_set_row,
|
||||
)
|
||||
from lnbits.db import DB_TYPE, SQLITE, Compat, Connection, Database
|
||||
from lnbits.settings import Settings
|
||||
from tests.helpers import make_installable_extension
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_core_wasm_migrations_create_persistent_columns(
|
||||
tmp_path: Path, settings: Settings
|
||||
):
|
||||
if DB_TYPE != SQLITE:
|
||||
pytest.skip("temporary core databases are SQLite-only")
|
||||
|
||||
db = _temporary_database(tmp_path, settings, "wasm_core_migrations")
|
||||
|
||||
async with db.connect() as conn:
|
||||
await m010_create_installed_extensions_table(conn)
|
||||
await m046_add_permissions_to_installed_extensions(conn)
|
||||
await m047_create_wasm_invocations_table(conn)
|
||||
await m048_add_wasm_runtime_limits_to_installed_extensions(conn)
|
||||
|
||||
installed_columns = {
|
||||
row["name"]
|
||||
for row in await conn.fetchall("PRAGMA table_info(installed_extensions)")
|
||||
}
|
||||
invocation_columns = {
|
||||
row["name"]
|
||||
for row in await conn.fetchall("PRAGMA table_info(wasm_invocations)")
|
||||
}
|
||||
|
||||
assert {"permissions", "wasm_runtime_limits"}.issubset(installed_columns)
|
||||
assert {
|
||||
"extension_id",
|
||||
"status",
|
||||
"request_bytes",
|
||||
"response_bytes",
|
||||
"host_call_count",
|
||||
"http_call_count",
|
||||
"storage_call_count",
|
||||
"wallet_call_count",
|
||||
"context",
|
||||
}.issubset(invocation_columns)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_installed_extension_permissions_and_wasm_limits_round_trip(
|
||||
app,
|
||||
tmp_path: Path,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
db = await _temporary_core_crud_database(tmp_path, settings)
|
||||
mocker.patch.object(extension_crud, "db", db)
|
||||
|
||||
ext_id = f"wasmlimits_{uuid4().hex[:8]}"
|
||||
extension = make_installable_extension(ext_id)
|
||||
extension.permissions = [ExtensionPermission(id="utils.basic")]
|
||||
extension.wasm_runtime_limits = {"wasm_runtime_max_execution_ms": 1234}
|
||||
|
||||
await extension_crud.create_installed_extension(extension)
|
||||
stored = await extension_crud.get_installed_extension(ext_id)
|
||||
|
||||
assert stored is not None
|
||||
assert stored.permissions == [ExtensionPermission(id="utils.basic")]
|
||||
assert stored.wasm_runtime_limits == {"wasm_runtime_max_execution_ms": 1234}
|
||||
|
||||
stored.wasm_runtime_limits = {"wasm_runtime_max_fuel": 0}
|
||||
await extension_crud.update_installed_extension(stored)
|
||||
updated = await extension_crud.get_installed_extension(ext_id)
|
||||
|
||||
assert updated is not None
|
||||
assert updated.wasm_runtime_limits == {"wasm_runtime_max_fuel": 0}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_crud_stats_and_cleanup_are_isolated(
|
||||
app,
|
||||
tmp_path: Path,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
db = await _temporary_core_crud_database(tmp_path, settings)
|
||||
mocker.patch.object(extension_crud, "db", db)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old = now - timedelta(days=10)
|
||||
ext_id = f"wasminv_{uuid4().hex[:8]}"
|
||||
other_ext_id = f"wasminv_{uuid4().hex[:8]}"
|
||||
invocations = [
|
||||
WasmInvocation(
|
||||
id=f"inv_{uuid4().hex}",
|
||||
extension_id=ext_id,
|
||||
export_name="render",
|
||||
status="completed",
|
||||
started_at=old,
|
||||
finished_at=old + timedelta(milliseconds=20),
|
||||
duration_ms=20,
|
||||
host_call_count=2,
|
||||
http_call_count=1,
|
||||
),
|
||||
WasmInvocation(
|
||||
id=f"inv_{uuid4().hex}",
|
||||
extension_id=ext_id,
|
||||
export_name="render",
|
||||
status="failed",
|
||||
started_at=now,
|
||||
finished_at=now,
|
||||
duration_ms=40,
|
||||
host_call_count=3,
|
||||
storage_call_count=2,
|
||||
),
|
||||
WasmInvocation(
|
||||
id=f"inv_{uuid4().hex}",
|
||||
extension_id=ext_id,
|
||||
export_name="render",
|
||||
status="running",
|
||||
started_at=old,
|
||||
),
|
||||
WasmInvocation(
|
||||
id=f"inv_{uuid4().hex}",
|
||||
extension_id=other_ext_id,
|
||||
export_name="render",
|
||||
status="completed",
|
||||
started_at=now,
|
||||
duration_ms=100,
|
||||
),
|
||||
]
|
||||
for invocation in invocations:
|
||||
await extension_crud.create_wasm_invocation(invocation)
|
||||
|
||||
failed = await extension_crud.get_wasm_invocations(
|
||||
extension_id=ext_id,
|
||||
status="failed",
|
||||
)
|
||||
stats = await extension_crud.get_wasm_invocation_stats(
|
||||
extension_id=ext_id,
|
||||
since=now - timedelta(days=30),
|
||||
)
|
||||
deleted = await extension_crud.delete_old_wasm_invocations(retention_days=7)
|
||||
remaining_running = await extension_crud.get_wasm_invocations(
|
||||
extension_id=ext_id,
|
||||
status="running",
|
||||
)
|
||||
|
||||
assert [invocation.id for invocation in failed] == [invocations[1].id]
|
||||
assert stats.total == 3
|
||||
assert stats.completed == 1
|
||||
assert stats.failed == 1
|
||||
assert stats.running == 1
|
||||
assert stats.host_call_count == 5
|
||||
assert stats.http_call_count == 1
|
||||
assert stats.storage_call_count == 2
|
||||
assert deleted == 1
|
||||
assert [invocation.id for invocation in remaining_running] == [invocations[2].id]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_datetime_queries_use_postgres_placeholders(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
db = mocker.Mock()
|
||||
db.timestamp_placeholder.side_effect = lambda key: f"to_timestamp(:{key})"
|
||||
db.fetchone = mocker.AsyncMock(return_value=None)
|
||||
db.execute = mocker.AsyncMock(return_value=SimpleNamespace(rowcount=0))
|
||||
|
||||
field = {"name": "created_at", "type": "datetime"}
|
||||
assert (
|
||||
storage_crud._value_placeholder(cast(Compat, db), field, "created_at")
|
||||
== "to_timestamp(:created_at)"
|
||||
)
|
||||
where_sql, _ = storage_crud._where_sql(
|
||||
cast(Compat, db), {"fields": [field]}, {"created_at": 0}, None, []
|
||||
)
|
||||
assert "created_at = to_timestamp(:filter_created_at)" in where_sql
|
||||
|
||||
conn = cast(Connection, db)
|
||||
await extension_crud.get_wasm_invocation_stats(
|
||||
since=datetime.now(timezone.utc), conn=conn
|
||||
)
|
||||
await extension_crud.delete_old_wasm_invocations(1, conn=conn)
|
||||
await extension_crud.mark_stale_wasm_invocations(conn=conn)
|
||||
|
||||
queries = [
|
||||
db.fetchone.call_args.args[0],
|
||||
*[c.args[0] for c in db.execute.call_args_list],
|
||||
]
|
||||
assert all("to_timestamp(:" in query for query in queries)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_storage_migration_and_owner_scoped_crud(
|
||||
tmp_path: Path,
|
||||
settings: Settings,
|
||||
):
|
||||
ext_id = f"wasmstore_{uuid4().hex[:8]}"
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
try:
|
||||
settings.lnbits_data_folder = str(tmp_path / "data")
|
||||
settings.lnbits_extensions_path = str(tmp_path / "code")
|
||||
Path(settings.lnbits_data_folder).mkdir(parents=True)
|
||||
ext_dir = _write_storage_extension(settings, ext_id)
|
||||
|
||||
await migrate_wasm_extension_database(make_installable_extension(ext_id))
|
||||
await storage_set_row(
|
||||
ext_id,
|
||||
"notes",
|
||||
{
|
||||
"id": "note-1",
|
||||
"title": "First",
|
||||
"count": 1,
|
||||
"published": True,
|
||||
"tags": ["alpha", "beta"],
|
||||
"created_at": 1_700_000_000,
|
||||
},
|
||||
"owner-1",
|
||||
)
|
||||
await storage_set_row(
|
||||
ext_id,
|
||||
"notes",
|
||||
{
|
||||
"id": "note-1",
|
||||
"title": "Other owner attempt",
|
||||
"count": 2,
|
||||
"published": False,
|
||||
"tags": ["gamma"],
|
||||
"created_at": 1_700_000_001,
|
||||
},
|
||||
"owner-2",
|
||||
)
|
||||
|
||||
owner_row = await storage_get_row(ext_id, "notes", "note-1", "owner-1")
|
||||
other_owner_row = await storage_get_row(ext_id, "notes", "note-1", "owner-2")
|
||||
public_row = await storage_get_public_row(ext_id, "notes", "note-1")
|
||||
page = await storage_get_paginated_rows(
|
||||
ext_id,
|
||||
"notes",
|
||||
{"published": True},
|
||||
owner_id="owner-1",
|
||||
search="fir",
|
||||
search_fields=["title"],
|
||||
sort_by="count",
|
||||
descending=True,
|
||||
limit=50,
|
||||
offset=0,
|
||||
)
|
||||
await storage_delete_row(ext_id, "notes", "note-1", "owner-2")
|
||||
still_owned = await storage_get_row(ext_id, "notes", "note-1", "owner-1")
|
||||
await storage_delete_row(ext_id, "notes", "note-1", "owner-1")
|
||||
deleted = await storage_get_row(ext_id, "notes", "note-1", "owner-1")
|
||||
finally:
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
|
||||
assert ext_dir.is_dir()
|
||||
assert owner_row is not None
|
||||
assert owner_row["title"] == "First"
|
||||
assert owner_row["tags"] == ["alpha", "beta"]
|
||||
assert owner_row["created_at"] == 1_700_000_000
|
||||
assert other_owner_row is None
|
||||
assert public_row is not None
|
||||
assert public_row["title"] == "First"
|
||||
assert page["total"] == 1
|
||||
assert page["data"][0]["id"] == "note-1"
|
||||
assert still_owned is not None
|
||||
assert deleted is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
|
||||
tmp_path: Path,
|
||||
settings: Settings,
|
||||
):
|
||||
ext_id = f"wasmstore_{uuid4().hex[:8]}"
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
original_data_folder = settings.lnbits_data_folder
|
||||
try:
|
||||
settings.lnbits_data_folder = str(tmp_path / "data")
|
||||
settings.lnbits_extensions_path = str(tmp_path / "code")
|
||||
Path(settings.lnbits_data_folder).mkdir(parents=True)
|
||||
_write_storage_extension(settings, ext_id)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid WASM storage SQL identifier"):
|
||||
await storage_get_row(ext_id, "notes;DROP", "note-1", "owner-1")
|
||||
|
||||
with pytest.raises(ValueError, match="reserved owner field"):
|
||||
await storage_set_row(
|
||||
ext_id,
|
||||
"notes",
|
||||
{"id": "note-1", OWNER_ID_FIELD: "owner-2"},
|
||||
"owner-1",
|
||||
)
|
||||
|
||||
schema_path = (
|
||||
Path(settings.lnbits_extensions_path)
|
||||
/ "extensions"
|
||||
/ ext_id
|
||||
/ "storage"
|
||||
/ "schema.json"
|
||||
)
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
schema["tables"]["notes"]["fields"].append(
|
||||
{"name": OWNER_ID_FIELD, "type": "string"}
|
||||
)
|
||||
schema_path.write_text(json.dumps(schema), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="reserved field"):
|
||||
await storage_get_row(ext_id, "notes", "note-1", "owner-1")
|
||||
finally:
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
settings.lnbits_data_folder = original_data_folder
|
||||
|
||||
|
||||
def _temporary_database(
|
||||
tmp_path: Path,
|
||||
settings: Settings,
|
||||
name: str,
|
||||
) -> Database:
|
||||
settings.lnbits_data_folder = str(tmp_path / "data")
|
||||
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
|
||||
return Database(name)
|
||||
|
||||
|
||||
async def _temporary_core_crud_database(
|
||||
tmp_path: Path,
|
||||
settings: Settings,
|
||||
) -> Database:
|
||||
if DB_TYPE != SQLITE:
|
||||
return core_db
|
||||
|
||||
db = _temporary_database(tmp_path, settings, f"core_{uuid4().hex[:8]}")
|
||||
async with db.connect() as conn:
|
||||
await conn.execute("""
|
||||
CREATE TABLE dbversions (
|
||||
db TEXT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
)
|
||||
""")
|
||||
await update_migration_version(conn, "core", 48)
|
||||
await m010_create_installed_extensions_table(conn)
|
||||
await m046_add_permissions_to_installed_extensions(conn)
|
||||
await m047_create_wasm_invocations_table(conn)
|
||||
await m048_add_wasm_runtime_limits_to_installed_extensions(conn)
|
||||
return db
|
||||
|
||||
|
||||
def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
|
||||
ext_dir = Path(settings.lnbits_extensions_path) / "extensions" / ext_id
|
||||
storage_dir = ext_dir / "storage"
|
||||
migrations_dir = storage_dir / "migrations"
|
||||
migrations_dir.mkdir(parents=True)
|
||||
schema = {
|
||||
"tables": {
|
||||
"notes": {
|
||||
"fields": [
|
||||
{"name": "id", "type": "string"},
|
||||
{"name": "title", "type": "string"},
|
||||
{"name": "count", "type": "integer", "default": 0},
|
||||
{"name": "published", "type": "boolean", "default": False},
|
||||
{"name": "tags", "type": "string", "list": True},
|
||||
{"name": "created_at", "type": "datetime"},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
migration = {
|
||||
"operations": [
|
||||
{
|
||||
"op": "create_table",
|
||||
"table": "notes",
|
||||
"fields": schema["tables"]["notes"]["fields"],
|
||||
}
|
||||
]
|
||||
}
|
||||
(storage_dir / "schema.json").write_text(json.dumps(schema), encoding="utf-8")
|
||||
(migrations_dir / "001_init.json").write_text(
|
||||
json.dumps(migration),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return ext_dir
|
||||
Reference in New Issue
Block a user