Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a85a5144d0 | ||
|
|
84bf855810 |
@@ -25,7 +25,6 @@ from .payments import (
|
|||||||
DateTrunc,
|
DateTrunc,
|
||||||
check_internal,
|
check_internal,
|
||||||
create_payment,
|
create_payment,
|
||||||
delete_expired_invoices,
|
|
||||||
delete_wallet_payment,
|
delete_wallet_payment,
|
||||||
get_latest_payments_by_extension,
|
get_latest_payments_by_extension,
|
||||||
get_payment,
|
get_payment,
|
||||||
@@ -103,7 +102,6 @@ __all__ = [
|
|||||||
"delete_accounts_no_wallets",
|
"delete_accounts_no_wallets",
|
||||||
"delete_admin_settings",
|
"delete_admin_settings",
|
||||||
"delete_dbversion",
|
"delete_dbversion",
|
||||||
"delete_expired_invoices",
|
|
||||||
"delete_installed_extension",
|
"delete_installed_extension",
|
||||||
"delete_tinyurl",
|
"delete_tinyurl",
|
||||||
"delete_unused_wallets",
|
"delete_unused_wallets",
|
||||||
|
|||||||
@@ -238,32 +238,6 @@ 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(
|
async def create_payment(
|
||||||
checking_id: str,
|
checking_id: str,
|
||||||
data: CreatePayment,
|
data: CreatePayment,
|
||||||
|
|||||||
@@ -374,6 +374,13 @@ async def update_pending_payments(wallet_id: str):
|
|||||||
async def update_pending_payment(
|
async def update_pending_payment(
|
||||||
payment: Payment, conn: Connection | None = None
|
payment: Payment, conn: Connection | None = None
|
||||||
) -> Payment:
|
) -> 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)
|
status = await check_payment_status(payment)
|
||||||
if status.failed:
|
if status.failed:
|
||||||
payment.status = PaymentState.FAILED
|
payment.status = PaymentState.FAILED
|
||||||
|
|||||||
@@ -11,15 +11,10 @@ from lnbits.helpers import sha256s
|
|||||||
|
|
||||||
from ..client.extensions import send_extension_api_request
|
from ..client.extensions import send_extension_api_request
|
||||||
from ..storage.crud import (
|
from ..storage.crud import (
|
||||||
OWNER_ID_FIELD,
|
|
||||||
storage_append_public_row,
|
|
||||||
storage_count_rows,
|
|
||||||
storage_delete_row,
|
storage_delete_row,
|
||||||
storage_get_paginated_rows,
|
storage_get_paginated_rows,
|
||||||
storage_get_public_paginated_rows,
|
|
||||||
storage_get_public_row,
|
storage_get_public_row,
|
||||||
storage_get_row,
|
storage_get_row,
|
||||||
storage_get_row_owner_id,
|
|
||||||
storage_set_row,
|
storage_set_row,
|
||||||
)
|
)
|
||||||
from .background_payments import (
|
from .background_payments import (
|
||||||
@@ -44,28 +39,21 @@ from .models import (
|
|||||||
PayLnurlRequest,
|
PayLnurlRequest,
|
||||||
RandomIdRequest,
|
RandomIdRequest,
|
||||||
RandomIdResponse,
|
RandomIdResponse,
|
||||||
StorageAppendPublicRequest,
|
|
||||||
StorageAppendPublicResponse,
|
|
||||||
StorageDeleteRequest,
|
StorageDeleteRequest,
|
||||||
StorageDeleteResponse,
|
StorageDeleteResponse,
|
||||||
StorageGetRequest,
|
StorageGetRequest,
|
||||||
StorageGetResponse,
|
StorageGetResponse,
|
||||||
StoragePaginatedRequest,
|
StoragePaginatedRequest,
|
||||||
StoragePaginatedResponse,
|
StoragePaginatedResponse,
|
||||||
StoragePublicPaginatedRequest,
|
|
||||||
StorageSetRequest,
|
StorageSetRequest,
|
||||||
StorageSetResponse,
|
StorageSetResponse,
|
||||||
UserWalletSummary,
|
UserWalletSummary,
|
||||||
WalletBalanceRequest,
|
WalletBalanceRequest,
|
||||||
WalletBalanceResponse,
|
WalletBalanceResponse,
|
||||||
WebsocketPublishRequest,
|
|
||||||
WebsocketPublishResponse,
|
|
||||||
)
|
)
|
||||||
from .registry import extension_api_method
|
from .registry import extension_api_method
|
||||||
from .websockets import scoped_websocket_item_id
|
|
||||||
|
|
||||||
logger = logging.getLogger("lnbits.extensions")
|
logger = logging.getLogger("lnbits.extensions")
|
||||||
PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionHostAPI:
|
class ExtensionHostAPI:
|
||||||
@@ -129,7 +117,7 @@ class ExtensionHostAPI:
|
|||||||
async def storage_get_public(
|
async def storage_get_public(
|
||||||
self, request: StorageGetRequest
|
self, request: StorageGetRequest
|
||||||
) -> StorageGetResponse:
|
) -> StorageGetResponse:
|
||||||
public_fields = self._public_storage_policy(request.table)["public_fields"]
|
public_fields = self._public_storage_fields(request.table)
|
||||||
row = await storage_get_public_row(self.extension_id, request.table, request.id)
|
row = await storage_get_public_row(self.extension_id, request.table, request.id)
|
||||||
if not row:
|
if not row:
|
||||||
return StorageGetResponse()
|
return StorageGetResponse()
|
||||||
@@ -141,45 +129,6 @@ class ExtensionHostAPI:
|
|||||||
# todo: check public fields filtering
|
# todo: check public fields filtering
|
||||||
return StorageGetResponse(data_json=json.dumps(public_row))
|
return StorageGetResponse(data_json=json.dumps(public_row))
|
||||||
|
|
||||||
@extension_api_method(
|
|
||||||
method_id="storage.append_public",
|
|
||||||
namespace="storage",
|
|
||||||
name="Append public storage row",
|
|
||||||
host_name="storage_append_public",
|
|
||||||
sdk_name="appendPublic",
|
|
||||||
description="Append one public row to an extension storage table.",
|
|
||||||
required_permission="ext.storage.append_public",
|
|
||||||
require_auth=False,
|
|
||||||
)
|
|
||||||
async def storage_append_public(
|
|
||||||
self, request: StorageAppendPublicRequest
|
|
||||||
) -> StorageAppendPublicResponse:
|
|
||||||
policy, owner_id = await self._public_storage_append_policy(
|
|
||||||
request.table, request.source_id
|
|
||||||
)
|
|
||||||
data = dict(request.data)
|
|
||||||
self._validate_public_append_data(policy, data)
|
|
||||||
|
|
||||||
source_id_field = policy["source_id_field"]
|
|
||||||
current_rows = await storage_count_rows(
|
|
||||||
self.extension_id,
|
|
||||||
request.table,
|
|
||||||
{source_id_field: request.source_id},
|
|
||||||
owner_id=owner_id,
|
|
||||||
)
|
|
||||||
if current_rows >= policy["max_rows_per_source"]:
|
|
||||||
raise PermissionError(
|
|
||||||
f"Public storage append limit reached for '{request.table}'."
|
|
||||||
)
|
|
||||||
|
|
||||||
row_id = await storage_append_public_row(
|
|
||||||
self.extension_id,
|
|
||||||
request.table,
|
|
||||||
{**data, source_id_field: request.source_id},
|
|
||||||
owner_id,
|
|
||||||
)
|
|
||||||
return StorageAppendPublicResponse(id=row_id)
|
|
||||||
|
|
||||||
@extension_api_method(
|
@extension_api_method(
|
||||||
method_id="storage.set",
|
method_id="storage.set",
|
||||||
namespace="storage",
|
namespace="storage",
|
||||||
@@ -229,55 +178,6 @@ class ExtensionHostAPI:
|
|||||||
total=page["total"],
|
total=page["total"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@extension_api_method(
|
|
||||||
method_id="storage.get_public_paginated",
|
|
||||||
namespace="storage",
|
|
||||||
name="Get paginated public storage rows",
|
|
||||||
host_name="storage_get_public_paginated",
|
|
||||||
sdk_name="getPublicPaginated",
|
|
||||||
description="Get filtered, searched, sorted, paginated public storage rows.",
|
|
||||||
required_permission="ext.storage.read_public",
|
|
||||||
require_auth=False,
|
|
||||||
)
|
|
||||||
async def storage_get_public_paginated(
|
|
||||||
self, request: StoragePublicPaginatedRequest
|
|
||||||
) -> StoragePaginatedResponse:
|
|
||||||
policy = self._public_storage_policy(request.table)
|
|
||||||
public_fields = policy["public_fields"]
|
|
||||||
source_id_field = policy["source_id_field"]
|
|
||||||
if not isinstance(source_id_field, str) or not source_id_field:
|
|
||||||
raise PermissionError(
|
|
||||||
"Public paginated storage reads require a source ID field policy."
|
|
||||||
)
|
|
||||||
|
|
||||||
filters = self._public_storage_paginated_filters(
|
|
||||||
request, public_fields, source_id_field
|
|
||||||
)
|
|
||||||
page = await storage_get_public_paginated_rows(
|
|
||||||
self.extension_id,
|
|
||||||
request.table,
|
|
||||||
filters,
|
|
||||||
search=request.search,
|
|
||||||
search_fields=request.search_fields,
|
|
||||||
sort_by=request.sort_by,
|
|
||||||
descending=request.descending,
|
|
||||||
limit=request.limit,
|
|
||||||
offset=request.offset,
|
|
||||||
)
|
|
||||||
return StoragePaginatedResponse(
|
|
||||||
rows_json=json.dumps(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
field_name: value
|
|
||||||
for field_name, value in row.items()
|
|
||||||
if field_name in public_fields
|
|
||||||
}
|
|
||||||
for row in page["data"]
|
|
||||||
]
|
|
||||||
),
|
|
||||||
total=page["total"],
|
|
||||||
)
|
|
||||||
|
|
||||||
@extension_api_method(
|
@extension_api_method(
|
||||||
method_id="storage.delete",
|
method_id="storage.delete",
|
||||||
namespace="storage",
|
namespace="storage",
|
||||||
@@ -299,25 +199,6 @@ class ExtensionHostAPI:
|
|||||||
)
|
)
|
||||||
return StorageDeleteResponse()
|
return StorageDeleteResponse()
|
||||||
|
|
||||||
@extension_api_method(
|
|
||||||
method_id="websocket.publish",
|
|
||||||
namespace="websocket",
|
|
||||||
name="Publish websocket message",
|
|
||||||
host_name="websocket_publish",
|
|
||||||
sdk_name="publish",
|
|
||||||
description="Publish a JSON message on an extension-local websocket channel.",
|
|
||||||
required_permission="websocket.publish",
|
|
||||||
require_auth=False,
|
|
||||||
)
|
|
||||||
async def websocket_publish(
|
|
||||||
self, request: WebsocketPublishRequest
|
|
||||||
) -> WebsocketPublishResponse:
|
|
||||||
from lnbits.core.services import websocket_manager
|
|
||||||
|
|
||||||
item_id = scoped_websocket_item_id(self.extension_id, request.item_id)
|
|
||||||
await websocket_manager.send(item_id, request.data_json)
|
|
||||||
return WebsocketPublishResponse()
|
|
||||||
|
|
||||||
@extension_api_method(
|
@extension_api_method(
|
||||||
method_id="wallet.create_invoice",
|
method_id="wallet.create_invoice",
|
||||||
namespace="wallet",
|
namespace="wallet",
|
||||||
@@ -747,7 +628,7 @@ class ExtensionHostAPI:
|
|||||||
|
|
||||||
return permission_ids, policies
|
return permission_ids, policies
|
||||||
|
|
||||||
def _public_storage_policy(self, table: str) -> dict[str, Any]:
|
def _public_storage_fields(self, table: str) -> set[str]:
|
||||||
tables = self.permission_policies.get("ext.storage.read_public")
|
tables = self.permission_policies.get("ext.storage.read_public")
|
||||||
if not isinstance(tables, list) or not tables:
|
if not isinstance(tables, list) or not tables:
|
||||||
raise PermissionError(
|
raise PermissionError(
|
||||||
@@ -767,148 +648,10 @@ class ExtensionHostAPI:
|
|||||||
raise PermissionError(
|
raise PermissionError(
|
||||||
f"Public storage table '{table}' has no valid public fields."
|
f"Public storage table '{table}' has no valid public fields."
|
||||||
)
|
)
|
||||||
source_id_field = table_policy.get("source_id_field")
|
return set(public_fields)
|
||||||
if source_id_field is not None and (
|
|
||||||
not isinstance(source_id_field, str) or not source_id_field
|
|
||||||
):
|
|
||||||
raise PermissionError(
|
|
||||||
f"Public storage table '{table}' has no valid source ID field."
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"public_fields": set(public_fields),
|
|
||||||
"source_id_field": source_id_field,
|
|
||||||
}
|
|
||||||
|
|
||||||
raise PermissionError(f"Storage table '{table}' is not publicly readable.")
|
raise PermissionError(f"Storage table '{table}' is not publicly readable.")
|
||||||
|
|
||||||
def _validate_public_storage_query_fields(
|
|
||||||
self,
|
|
||||||
request: StoragePaginatedRequest,
|
|
||||||
public_fields: set[str],
|
|
||||||
allowed_private_fields: set[str] | None = None,
|
|
||||||
) -> None:
|
|
||||||
allowed_private_fields = allowed_private_fields or set()
|
|
||||||
query_fields = set(request.filters)
|
|
||||||
query_fields.update(request.search_fields)
|
|
||||||
if request.sort_by:
|
|
||||||
query_fields.add(request.sort_by)
|
|
||||||
private_fields = sorted(query_fields - public_fields - allowed_private_fields)
|
|
||||||
if private_fields:
|
|
||||||
raise PermissionError(
|
|
||||||
"Public storage query uses non-public fields: "
|
|
||||||
+ ", ".join(private_fields)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _public_storage_paginated_filters(
|
|
||||||
self,
|
|
||||||
request: StoragePublicPaginatedRequest,
|
|
||||||
public_fields: set[str],
|
|
||||||
source_id_field: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
self._validate_public_storage_query_fields(
|
|
||||||
request, public_fields, {source_id_field}
|
|
||||||
)
|
|
||||||
filters = dict(request.filters)
|
|
||||||
requested_source_id = filters.get(source_id_field)
|
|
||||||
if requested_source_id is not None and requested_source_id != request.source_id:
|
|
||||||
raise PermissionError(
|
|
||||||
"Public storage source filter does not match source_id."
|
|
||||||
)
|
|
||||||
filters[source_id_field] = request.source_id
|
|
||||||
return filters
|
|
||||||
|
|
||||||
async def _public_storage_append_policy(
|
|
||||||
self, table: str, source_id: str
|
|
||||||
) -> tuple[dict[str, Any], str]:
|
|
||||||
policies = self.permission_policies.get("ext.storage.append_public")
|
|
||||||
if not isinstance(policies, list) or not policies:
|
|
||||||
raise PermissionError(
|
|
||||||
"Public storage appends require policies for "
|
|
||||||
"'ext.storage.append_public'."
|
|
||||||
)
|
|
||||||
|
|
||||||
source_not_found = False
|
|
||||||
for raw_policy in policies:
|
|
||||||
policy = self._normalize_public_storage_append_policy(raw_policy)
|
|
||||||
if policy["table"] != table:
|
|
||||||
continue
|
|
||||||
owner_id = await storage_get_row_owner_id(
|
|
||||||
self.extension_id,
|
|
||||||
policy["source_table"],
|
|
||||||
source_id,
|
|
||||||
)
|
|
||||||
if not owner_id:
|
|
||||||
source_not_found = True
|
|
||||||
continue
|
|
||||||
return policy, owner_id
|
|
||||||
|
|
||||||
if source_not_found:
|
|
||||||
raise PermissionError("Public storage append source was not found.")
|
|
||||||
raise PermissionError(f"Storage table '{table}' is not publicly appendable.")
|
|
||||||
|
|
||||||
def _normalize_public_storage_append_policy(self, policy: Any) -> dict[str, Any]:
|
|
||||||
if not isinstance(policy, dict):
|
|
||||||
raise PermissionError("Public storage append policies must be objects.")
|
|
||||||
|
|
||||||
table = policy.get("table")
|
|
||||||
source_table = policy.get("source_table")
|
|
||||||
source_id_field = policy.get("source_id_field")
|
|
||||||
allowed_fields = policy.get("allowed_fields")
|
|
||||||
max_rows_per_source = policy.get(
|
|
||||||
"max_rows_per_source", PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE
|
|
||||||
)
|
|
||||||
|
|
||||||
if not isinstance(table, str) or not table:
|
|
||||||
raise PermissionError("Public storage append requires a table policy.")
|
|
||||||
if not isinstance(source_table, str) or not source_table:
|
|
||||||
raise PermissionError(
|
|
||||||
"Public storage append requires a source table policy."
|
|
||||||
)
|
|
||||||
if not isinstance(source_id_field, str) or not source_id_field:
|
|
||||||
raise PermissionError(
|
|
||||||
"Public storage append requires a source ID field policy."
|
|
||||||
)
|
|
||||||
if source_id_field == "id":
|
|
||||||
raise PermissionError("Public storage append source field cannot be 'id'.")
|
|
||||||
if (
|
|
||||||
not isinstance(allowed_fields, list)
|
|
||||||
or not all(isinstance(field, str) and field for field in allowed_fields)
|
|
||||||
or "id" in allowed_fields
|
|
||||||
or OWNER_ID_FIELD in allowed_fields
|
|
||||||
or source_id_field in allowed_fields
|
|
||||||
):
|
|
||||||
raise PermissionError(
|
|
||||||
"Public storage append requires valid allowed fields."
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
isinstance(max_rows_per_source, bool)
|
|
||||||
or not isinstance(max_rows_per_source, int)
|
|
||||||
or max_rows_per_source <= 0
|
|
||||||
):
|
|
||||||
raise PermissionError(
|
|
||||||
"Public storage append requires a positive row limit."
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"table": table,
|
|
||||||
"source_table": source_table,
|
|
||||||
"source_id_field": source_id_field,
|
|
||||||
"allowed_fields": set(allowed_fields),
|
|
||||||
"max_rows_per_source": max_rows_per_source,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _validate_public_append_data(
|
|
||||||
self, policy: dict[str, Any], data: dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise PermissionError("Public storage append data must be an object.")
|
|
||||||
unknown_fields = sorted(set(data) - policy["allowed_fields"])
|
|
||||||
if unknown_fields:
|
|
||||||
raise PermissionError(
|
|
||||||
"Public storage append contains disallowed fields: "
|
|
||||||
+ ", ".join(unknown_fields)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _public_invoice_wallet_sources(self) -> list[dict[str, str]]:
|
def _public_invoice_wallet_sources(self) -> list[dict[str, str]]:
|
||||||
policies = self.permission_policies.get("wallet.create_invoice_public")
|
policies = self.permission_policies.get("wallet.create_invoice_public")
|
||||||
if not isinstance(policies, list) or not policies:
|
if not isinstance(policies, list) or not policies:
|
||||||
|
|||||||
@@ -67,23 +67,6 @@ class StorageSetResponse(BaseModel):
|
|||||||
ok: bool = True
|
ok: bool = True
|
||||||
|
|
||||||
|
|
||||||
class StorageAppendPublicRequest(BaseModel):
|
|
||||||
table: str = Field(..., min_length=1, max_length=128)
|
|
||||||
source_id: str = Field(..., min_length=1, max_length=512)
|
|
||||||
data: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
@root_validator(pre=True)
|
|
||||||
def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
data_json = values.get("data_json")
|
|
||||||
if data_json is not None and "data" not in values:
|
|
||||||
values["data"] = json.loads(data_json)
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
class StorageAppendPublicResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
|
|
||||||
|
|
||||||
class StoragePaginatedRequest(BaseModel):
|
class StoragePaginatedRequest(BaseModel):
|
||||||
table: str = Field(..., min_length=1, max_length=128)
|
table: str = Field(..., min_length=1, max_length=128)
|
||||||
filters: dict[str, Any] = Field(default_factory=dict)
|
filters: dict[str, Any] = Field(default_factory=dict)
|
||||||
@@ -109,47 +92,11 @@ class StoragePaginatedRequest(BaseModel):
|
|||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
class StoragePublicPaginatedRequest(StoragePaginatedRequest):
|
|
||||||
source_id: str = Field(..., min_length=1, max_length=512)
|
|
||||||
|
|
||||||
|
|
||||||
class StoragePaginatedResponse(BaseModel):
|
class StoragePaginatedResponse(BaseModel):
|
||||||
rows_json: str = "[]"
|
rows_json: str = "[]"
|
||||||
total: int = 0
|
total: int = 0
|
||||||
|
|
||||||
|
|
||||||
class WebsocketPublishRequest(BaseModel):
|
|
||||||
item_id: str = Field(..., min_length=1, max_length=128)
|
|
||||||
data: Any = Field(default_factory=dict)
|
|
||||||
|
|
||||||
@root_validator(pre=True)
|
|
||||||
def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
data_json = values.get("data_json")
|
|
||||||
if data_json is not None and "data" not in values:
|
|
||||||
values["data"] = json.loads(data_json)
|
|
||||||
return values
|
|
||||||
|
|
||||||
@root_validator
|
|
||||||
def validate_data_size(cls, values: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
data = values.get("data")
|
|
||||||
try:
|
|
||||||
encoded = json.dumps(data, separators=(",", ":"))
|
|
||||||
except TypeError as exc:
|
|
||||||
raise ValueError("websocket data must be JSON serializable.") from exc
|
|
||||||
if len(encoded.encode()) > 65536:
|
|
||||||
raise ValueError("websocket data must not exceed 65536 bytes.")
|
|
||||||
values["data"] = data
|
|
||||||
return values
|
|
||||||
|
|
||||||
@property
|
|
||||||
def data_json(self) -> str:
|
|
||||||
return json.dumps(self.data, separators=(",", ":"))
|
|
||||||
|
|
||||||
|
|
||||||
class WebsocketPublishResponse(BaseModel):
|
|
||||||
sent: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class StorageDeleteRequest(BaseModel):
|
class StorageDeleteRequest(BaseModel):
|
||||||
table: str = Field(..., min_length=1, max_length=128)
|
table: str = Field(..., min_length=1, max_length=128)
|
||||||
id: str = Field(..., min_length=1, max_length=512)
|
id: str = Field(..., min_length=1, max_length=512)
|
||||||
|
|||||||
@@ -10,14 +10,11 @@ from lnbits.core.wasm_ext.wasm.config import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
_POLICY_AWARE_PERMISSION_IDS = {
|
_POLICY_AWARE_PERMISSION_IDS = {
|
||||||
"ext.storage.append_public",
|
|
||||||
"ext.storage.read_public",
|
"ext.storage.read_public",
|
||||||
"extension.api.request",
|
"extension.api.request",
|
||||||
"http.request",
|
"http.request",
|
||||||
"wallet.create_invoice_public",
|
"wallet.create_invoice_public",
|
||||||
}
|
}
|
||||||
_OWNER_ID_FIELD = "__lnbits_owner_id__"
|
|
||||||
_PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000
|
|
||||||
|
|
||||||
|
|
||||||
def validate_extension_permissions(
|
def validate_extension_permissions(
|
||||||
@@ -130,10 +127,6 @@ def _permission_grant_is_subset(
|
|||||||
return _http_request_grant_is_subset(requested.policies, granted.policies)
|
return _http_request_grant_is_subset(requested.policies, granted.policies)
|
||||||
if requested.id == "extension.api.request":
|
if requested.id == "extension.api.request":
|
||||||
return _extension_api_grant_is_subset(requested.policies, granted.policies)
|
return _extension_api_grant_is_subset(requested.policies, granted.policies)
|
||||||
if requested.id == "ext.storage.append_public":
|
|
||||||
return _public_storage_append_grant_is_subset(
|
|
||||||
requested.policies, granted.policies
|
|
||||||
)
|
|
||||||
if requested.id == "ext.storage.read_public":
|
if requested.id == "ext.storage.read_public":
|
||||||
return _public_storage_grant_is_subset(requested.policies, granted.policies)
|
return _public_storage_grant_is_subset(requested.policies, granted.policies)
|
||||||
if requested.id == "wallet.create_invoice_public":
|
if requested.id == "wallet.create_invoice_public":
|
||||||
@@ -210,108 +203,32 @@ def _public_storage_grant_is_subset(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
requested_tables = _public_storage_tables(requested_policies)
|
requested_tables = _public_storage_tables(requested_policies)
|
||||||
granted_tables = _public_storage_tables(granted_policies)
|
granted_tables = _public_storage_tables(granted_policies)
|
||||||
for table_name, granted_policy in granted_tables.items():
|
for table_name, granted_fields in granted_tables.items():
|
||||||
requested_policy = requested_tables.get(table_name)
|
requested_fields = requested_tables.get(table_name)
|
||||||
if requested_policy is None:
|
if requested_fields is None or not granted_fields.issubset(requested_fields):
|
||||||
return False
|
|
||||||
if not granted_policy["public_fields"].issubset(
|
|
||||||
requested_policy["public_fields"]
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
requested_source_id_field = requested_policy["source_id_field"]
|
|
||||||
if (
|
|
||||||
requested_source_id_field
|
|
||||||
and granted_policy["source_id_field"] != requested_source_id_field
|
|
||||||
):
|
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _public_storage_tables(policies: list[Any] | None) -> dict[str, dict[str, Any]]:
|
def _public_storage_tables(policies: list[Any] | None) -> dict[str, set[str]]:
|
||||||
tables: dict[str, dict[str, Any]] = {}
|
tables: dict[str, set[str]] = {}
|
||||||
for policy in _policy_list(policies):
|
for policy in _policy_list(policies):
|
||||||
if not isinstance(policy, dict):
|
if not isinstance(policy, dict):
|
||||||
continue
|
continue
|
||||||
table_name = policy.get("table_name")
|
table_name = policy.get("table_name")
|
||||||
public_fields = policy.get("public_fields")
|
public_fields = policy.get("public_fields")
|
||||||
source_id_field = policy.get("source_id_field")
|
|
||||||
if (
|
if (
|
||||||
not isinstance(table_name, str)
|
not isinstance(table_name, str)
|
||||||
or table_name in tables
|
or table_name in tables
|
||||||
or not isinstance(public_fields, list)
|
or not isinstance(public_fields, list)
|
||||||
or (
|
|
||||||
source_id_field is not None
|
|
||||||
and (not isinstance(source_id_field, str) or not source_id_field)
|
|
||||||
)
|
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
fields = {field for field in public_fields if isinstance(field, str) and field}
|
fields = {field for field in public_fields if isinstance(field, str) and field}
|
||||||
if fields:
|
if fields:
|
||||||
tables[table_name] = {
|
tables[table_name] = fields
|
||||||
"public_fields": fields,
|
|
||||||
"source_id_field": source_id_field,
|
|
||||||
}
|
|
||||||
return tables
|
return tables
|
||||||
|
|
||||||
|
|
||||||
def _public_storage_append_grant_is_subset(
|
|
||||||
requested_policies: list[Any] | None,
|
|
||||||
granted_policies: list[Any] | None,
|
|
||||||
) -> bool:
|
|
||||||
requested_targets = _public_storage_append_targets(requested_policies)
|
|
||||||
granted_targets = _public_storage_append_targets(granted_policies)
|
|
||||||
for target, granted_policy in granted_targets.items():
|
|
||||||
requested_policy = requested_targets.get(target)
|
|
||||||
if requested_policy is None:
|
|
||||||
return False
|
|
||||||
if not granted_policy["allowed_fields"].issubset(
|
|
||||||
requested_policy["allowed_fields"]
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
if (
|
|
||||||
granted_policy["max_rows_per_source"]
|
|
||||||
> requested_policy["max_rows_per_source"]
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _public_storage_append_targets(policies: list[Any] | None) -> dict[Any, dict]:
|
|
||||||
targets: dict[Any, dict] = {}
|
|
||||||
for policy in _policy_list(policies):
|
|
||||||
if not isinstance(policy, dict):
|
|
||||||
continue
|
|
||||||
table = policy.get("table")
|
|
||||||
source_table = policy.get("source_table")
|
|
||||||
source_id_field = policy.get("source_id_field")
|
|
||||||
allowed_fields = policy.get("allowed_fields")
|
|
||||||
max_rows_per_source = policy.get(
|
|
||||||
"max_rows_per_source", _PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
not isinstance(table, str)
|
|
||||||
or not table
|
|
||||||
or not isinstance(source_table, str)
|
|
||||||
or not source_table
|
|
||||||
or not isinstance(source_id_field, str)
|
|
||||||
or not source_id_field
|
|
||||||
or source_id_field == "id"
|
|
||||||
or not isinstance(allowed_fields, list)
|
|
||||||
or isinstance(max_rows_per_source, bool)
|
|
||||||
or not isinstance(max_rows_per_source, int)
|
|
||||||
or max_rows_per_source <= 0
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
fields = {field for field in allowed_fields if isinstance(field, str) and field}
|
|
||||||
if "id" in fields or _OWNER_ID_FIELD in fields or source_id_field in fields:
|
|
||||||
continue
|
|
||||||
targets[(table, source_table, source_id_field)] = {
|
|
||||||
"allowed_fields": fields,
|
|
||||||
"max_rows_per_source": max_rows_per_source,
|
|
||||||
}
|
|
||||||
return targets
|
|
||||||
|
|
||||||
|
|
||||||
def _public_invoice_grant_is_subset(
|
def _public_invoice_grant_is_subset(
|
||||||
requested_policies: list[Any] | None,
|
requested_policies: list[Any] | None,
|
||||||
granted_policies: list[Any] | None,
|
granted_policies: list[Any] | None,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ _EXTENSION_RUNTIME_PERMISSION_IDS = {
|
|||||||
"wallet.pay_invoice",
|
"wallet.pay_invoice",
|
||||||
"wallet.pay_invoice_background",
|
"wallet.pay_invoice_background",
|
||||||
"wallet.payments.watch",
|
"wallet.payments.watch",
|
||||||
"websocket.subscribe",
|
|
||||||
}
|
}
|
||||||
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
|
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
|
||||||
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
|
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
|
||||||
_LOCAL_ITEM_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_-]{0,127}$")
|
|
||||||
|
|
||||||
|
|
||||||
def scoped_websocket_item_id(extension_id: str, item_id: str) -> str:
|
|
||||||
if not _EXTENSION_ID_RE.fullmatch(extension_id):
|
|
||||||
raise ValueError("Extension websocket namespace is invalid.")
|
|
||||||
if not _LOCAL_ITEM_ID_RE.fullmatch(item_id):
|
|
||||||
raise ValueError(
|
|
||||||
"Extension websocket item ID must be 1-128 characters and contain "
|
|
||||||
"only letters, numbers, colon, underscore, or dash."
|
|
||||||
)
|
|
||||||
return f"ext:{extension_id}:{item_id}"
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
from .crud import (
|
from .crud import (
|
||||||
migrate_wasm_extension_database,
|
migrate_wasm_extension_database,
|
||||||
storage_append_public_row,
|
|
||||||
storage_count_rows,
|
|
||||||
storage_delete_row,
|
storage_delete_row,
|
||||||
storage_get_paginated_rows,
|
storage_get_paginated_rows,
|
||||||
storage_get_public_paginated_rows,
|
|
||||||
storage_get_public_row,
|
storage_get_public_row,
|
||||||
storage_get_row,
|
storage_get_row,
|
||||||
storage_get_row_owner_id,
|
storage_get_row_owner_id,
|
||||||
@@ -13,11 +10,8 @@ from .crud import (
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"migrate_wasm_extension_database",
|
"migrate_wasm_extension_database",
|
||||||
"storage_append_public_row",
|
|
||||||
"storage_count_rows",
|
|
||||||
"storage_delete_row",
|
"storage_delete_row",
|
||||||
"storage_get_paginated_rows",
|
"storage_get_paginated_rows",
|
||||||
"storage_get_public_paginated_rows",
|
|
||||||
"storage_get_public_row",
|
"storage_get_public_row",
|
||||||
"storage_get_row",
|
"storage_get_row",
|
||||||
"storage_get_row_owner_id",
|
"storage_get_row_owner_id",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import re
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -111,39 +110,6 @@ async def storage_set_row(
|
|||||||
await conn.execute(query, clean_data)
|
await conn.execute(query, clean_data)
|
||||||
|
|
||||||
|
|
||||||
async def storage_append_public_row(
|
|
||||||
ext_id: str,
|
|
||||||
table: str,
|
|
||||||
data: dict[str, Any],
|
|
||||||
owner_id: str,
|
|
||||||
) -> str:
|
|
||||||
row_id = uuid4().hex
|
|
||||||
await storage_set_row(ext_id, table, {**data, "id": row_id}, owner_id)
|
|
||||||
return row_id
|
|
||||||
|
|
||||||
|
|
||||||
async def storage_count_rows(
|
|
||||||
ext_id: str,
|
|
||||||
table: str,
|
|
||||||
filters: dict[str, Any],
|
|
||||||
*,
|
|
||||||
owner_id: str,
|
|
||||||
) -> int:
|
|
||||||
table_schema = _load_table_schema(ext_id, table)
|
|
||||||
database = Database(f"ext_{ext_id}")
|
|
||||||
where_sql, values = _where_sql(database, table_schema, filters, None, [])
|
|
||||||
where_sql = _append_owner_where_sql(where_sql)
|
|
||||||
values[OWNER_ID_FIELD] = owner_id
|
|
||||||
|
|
||||||
query = f"""
|
|
||||||
SELECT COUNT(*) AS count FROM {_table_ref_for_schema(ext_id, table)}
|
|
||||||
{where_sql}
|
|
||||||
""" # noqa: S608
|
|
||||||
async with database.connect() as conn:
|
|
||||||
row = await conn.fetchone(query, values)
|
|
||||||
return int(row["count"]) if row else 0
|
|
||||||
|
|
||||||
|
|
||||||
async def storage_get_paginated_rows(
|
async def storage_get_paginated_rows(
|
||||||
ext_id: str,
|
ext_id: str,
|
||||||
table: str,
|
table: str,
|
||||||
@@ -191,50 +157,6 @@ async def storage_get_paginated_rows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def storage_get_public_paginated_rows(
|
|
||||||
ext_id: str,
|
|
||||||
table: str,
|
|
||||||
filters: dict[str, Any],
|
|
||||||
*,
|
|
||||||
search: str | None,
|
|
||||||
search_fields: list[str],
|
|
||||||
sort_by: str | None,
|
|
||||||
descending: bool,
|
|
||||||
limit: int,
|
|
||||||
offset: int,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
table_schema = _load_table_schema(ext_id, table)
|
|
||||||
database = Database(f"ext_{ext_id}")
|
|
||||||
where_sql, values = _where_sql(
|
|
||||||
database, table_schema, filters, search, search_fields
|
|
||||||
)
|
|
||||||
order_sql = _order_sql(table_schema, sort_by, descending)
|
|
||||||
count_values = dict(values)
|
|
||||||
values.update({"limit": min(limit, 1000), "offset": offset})
|
|
||||||
|
|
||||||
table_ref = _table_ref_for_schema(ext_id, table)
|
|
||||||
rows_query = f"""
|
|
||||||
SELECT * FROM {table_ref}
|
|
||||||
{where_sql}
|
|
||||||
{order_sql}
|
|
||||||
LIMIT :limit
|
|
||||||
OFFSET :offset
|
|
||||||
""" # noqa: S608
|
|
||||||
count_query = f"""
|
|
||||||
SELECT COUNT(*) AS count FROM {table_ref}
|
|
||||||
{where_sql}
|
|
||||||
""" # noqa: S608
|
|
||||||
|
|
||||||
async with database.connect() as conn:
|
|
||||||
rows = await conn.fetchall(rows_query, values)
|
|
||||||
count_row = await conn.fetchone(count_query, count_values)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"data": [_row_from_db(table_schema, row) for row in rows],
|
|
||||||
"total": int(count_row["count"]) if count_row else 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def storage_delete_row(
|
async def storage_delete_row(
|
||||||
ext_id: str,
|
ext_id: str,
|
||||||
table: str,
|
table: str,
|
||||||
|
|||||||
+6
-5
@@ -497,7 +497,7 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
|||||||
validated, errors = compare_field.validate(raw_value, {}, loc="none")
|
validated, errors = compare_field.validate(raw_value, {}, loc="none")
|
||||||
if errors:
|
if errors:
|
||||||
raise ValidationError(errors=[errors], model=model)
|
raise ValidationError(errors=[errors], model=model)
|
||||||
values[f"{field}__{index}"] = validated
|
values[f"{field}__{i}_{index}"] = validated
|
||||||
else:
|
else:
|
||||||
raise ValueError("Unknown filter field")
|
raise ValueError("Unknown filter field")
|
||||||
|
|
||||||
@@ -510,11 +510,12 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
|||||||
for key in self.values.keys() if self.values else []:
|
for key in self.values.keys() if self.values else []:
|
||||||
if self.model and self.model.__fields__[self.field].type_ == datetime:
|
if self.model and self.model.__fields__[self.field].type_ == datetime:
|
||||||
placeholder = compat_timestamp_placeholder(key)
|
placeholder = compat_timestamp_placeholder(key)
|
||||||
stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}")
|
|
||||||
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
|
|
||||||
stmt.append(f":{key}")
|
|
||||||
else:
|
else:
|
||||||
stmt.append(f"{prefix}{self.field} {self.op.as_sql} :{key}")
|
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}:
|
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
|
||||||
statement = f"{prefix}{self.field} {self.op.as_sql} ({', '.join(stmt)})"
|
statement = f"{prefix}{self.field} {self.op.as_sql} ({', '.join(stmt)})"
|
||||||
|
|||||||
+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
@@ -544,13 +544,7 @@ window.localisation.en = {
|
|||||||
extension_permission_warning_extension_api_request_write:
|
extension_permission_warning_extension_api_request_write:
|
||||||
'Can write data or trigger actions in approved extensions.',
|
'Can write data or trigger actions in approved extensions.',
|
||||||
extension_permission_ext_storage_read: 'Read extension storage',
|
extension_permission_ext_storage_read: 'Read extension storage',
|
||||||
extension_permission_ext_storage_append_public:
|
|
||||||
'Append public extension storage',
|
|
||||||
extension_permission_ext_storage_append_public_sources:
|
|
||||||
'Allowed append targets',
|
|
||||||
extension_permission_ext_storage_read_public: 'Read public extension storage',
|
extension_permission_ext_storage_read_public: 'Read public extension storage',
|
||||||
extension_permission_ext_storage_read_public_source_required:
|
|
||||||
'required to read',
|
|
||||||
extension_permission_ext_storage_write: 'Write extension storage',
|
extension_permission_ext_storage_write: 'Write extension storage',
|
||||||
extension_permission_ext_storage_read_write: 'Read & Write extension storage',
|
extension_permission_ext_storage_read_write: 'Read & Write extension storage',
|
||||||
extension_permission_extension_api_request: 'Use other extensions',
|
extension_permission_extension_api_request: 'Use other extensions',
|
||||||
@@ -561,9 +555,6 @@ window.localisation.en = {
|
|||||||
extension_permission_http_request_hosts: 'Allowed hosts',
|
extension_permission_http_request_hosts: 'Allowed hosts',
|
||||||
extension_permission_utils_basic: 'Use basic LNbits utilities',
|
extension_permission_utils_basic: 'Use basic LNbits utilities',
|
||||||
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
|
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
|
||||||
extension_permission_websocket: 'Use extension websockets',
|
|
||||||
extension_permission_websocket_publish: 'Publish websocket messages',
|
|
||||||
extension_permission_websocket_subscribe: 'Subscribe to websocket messages',
|
|
||||||
extension_permission_wallet_payments_watch: 'Watch wallet payments',
|
extension_permission_wallet_payments_watch: 'Watch wallet payments',
|
||||||
extension_permission_wallet_create_invoice: 'Create invoices',
|
extension_permission_wallet_create_invoice: 'Create invoices',
|
||||||
extension_permission_wallet_create_invoice_public:
|
extension_permission_wallet_create_invoice_public:
|
||||||
|
|||||||
@@ -110,15 +110,11 @@
|
|||||||
'extension_permission_warning_wallet_payments_watch'
|
'extension_permission_warning_wallet_payments_watch'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (['websocket.publish', 'websocket.subscribe'].includes(permission.id)) {
|
|
||||||
return mediumRisk(translateFn)
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
'wallet.list',
|
'wallet.list',
|
||||||
'wallet.balance.read',
|
'wallet.balance.read',
|
||||||
'wallet.create_invoice_public',
|
'wallet.create_invoice_public',
|
||||||
'ext.storage.append_public',
|
|
||||||
'ext.storage.read_public'
|
'ext.storage.read_public'
|
||||||
].includes(permission.id)
|
].includes(permission.id)
|
||||||
) {
|
) {
|
||||||
@@ -146,13 +142,9 @@
|
|||||||
'extension.api.request',
|
'extension.api.request',
|
||||||
'http.request',
|
'http.request',
|
||||||
'ui.camera.scan_qr',
|
'ui.camera.scan_qr',
|
||||||
'websocket',
|
|
||||||
'websocket.publish',
|
|
||||||
'websocket.subscribe',
|
|
||||||
'ext.storage.read',
|
'ext.storage.read',
|
||||||
'ext.storage.write',
|
'ext.storage.write',
|
||||||
'ext.storage.read_public',
|
'ext.storage.read_public',
|
||||||
'ext.storage.append_public',
|
|
||||||
'wallet.create_invoice_public',
|
'wallet.create_invoice_public',
|
||||||
'wallet.create_invoice',
|
'wallet.create_invoice',
|
||||||
'utils.basic'
|
'utils.basic'
|
||||||
@@ -174,12 +166,7 @@
|
|||||||
: table.public_fields.filter(
|
: table.public_fields.filter(
|
||||||
field => typeof field === 'string' && field
|
field => typeof field === 'string' && field
|
||||||
)
|
)
|
||||||
const sourceIdField =
|
return tableName ? {table: tableName, fields} : null
|
||||||
typeof table === 'string' ||
|
|
||||||
typeof table?.source_id_field !== 'string'
|
|
||||||
? ''
|
|
||||||
: table.source_id_field
|
|
||||||
return tableName ? {table: tableName, fields, sourceIdField} : null
|
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
@@ -207,68 +194,24 @@
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
function publicAppendPolicies(permission) {
|
|
||||||
const policies = permission.policies
|
|
||||||
if (!Array.isArray(policies)) return []
|
|
||||||
return policies
|
|
||||||
.map(policy => {
|
|
||||||
if (!policy || typeof policy !== 'object') return null
|
|
||||||
const table = policy.table
|
|
||||||
const sourceTable = policy.source_table
|
|
||||||
const sourceIdField = policy.source_id_field
|
|
||||||
const allowedFields = Array.isArray(policy.allowed_fields)
|
|
||||||
? policy.allowed_fields.filter(
|
|
||||||
field => typeof field === 'string' && field
|
|
||||||
)
|
|
||||||
: []
|
|
||||||
const maxRowsPerSource = Number.isInteger(policy.max_rows_per_source)
|
|
||||||
? policy.max_rows_per_source
|
|
||||||
: 10000
|
|
||||||
if (typeof table !== 'string' || !table) return null
|
|
||||||
if (typeof sourceTable !== 'string' || !sourceTable) return null
|
|
||||||
if (typeof sourceIdField !== 'string' || !sourceIdField) return null
|
|
||||||
return {
|
|
||||||
table,
|
|
||||||
sourceTable,
|
|
||||||
sourceIdField,
|
|
||||||
allowedFields,
|
|
||||||
maxRowsPerSource
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
function permissionDisplayItem(permissions, extensions, translateFn) {
|
function permissionDisplayItem(permissions, extensions, translateFn) {
|
||||||
const permission = permissions[0]
|
const permission = permissions[0]
|
||||||
const isReadWriteStorage =
|
const isReadWriteStorage =
|
||||||
permissions.length === 2 &&
|
permissions.length === 2 &&
|
||||||
permissions.some(permission => permission.id === 'ext.storage.read') &&
|
permissions.some(permission => permission.id === 'ext.storage.read') &&
|
||||||
permissions.some(permission => permission.id === 'ext.storage.write')
|
permissions.some(permission => permission.id === 'ext.storage.write')
|
||||||
const isWebsocket =
|
|
||||||
permissions.every(permission =>
|
|
||||||
['websocket.publish', 'websocket.subscribe'].includes(permission.id)
|
|
||||||
) &&
|
|
||||||
permissions.some(permission => permission.id === 'websocket.publish') &&
|
|
||||||
permissions.some(permission => permission.id === 'websocket.subscribe')
|
|
||||||
const descriptions = permissions
|
const descriptions = permissions
|
||||||
.map(permission => permissionManifestDescription(permission))
|
.map(permission => permissionManifestDescription(permission))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
const item = {
|
const item = {
|
||||||
id: isReadWriteStorage
|
id: isReadWriteStorage ? 'ext.storage.read_write' : permission.id,
|
||||||
? 'ext.storage.read_write'
|
|
||||||
: isWebsocket
|
|
||||||
? 'websocket'
|
|
||||||
: permission.id,
|
|
||||||
label: isReadWriteStorage
|
label: isReadWriteStorage
|
||||||
? translate(translateFn, 'extension_permission_ext_storage_read_write')
|
? translate(translateFn, 'extension_permission_ext_storage_read_write')
|
||||||
: isWebsocket
|
: permissionLabel(permission, translateFn),
|
||||||
? translate(translateFn, 'extension_permission_websocket')
|
|
||||||
: permissionLabel(permission, translateFn),
|
|
||||||
risk: permissionRisk(permissions, extensions, translateFn),
|
risk: permissionRisk(permissions, extensions, translateFn),
|
||||||
badges: [],
|
badges: [],
|
||||||
descriptions,
|
descriptions,
|
||||||
fieldGroups: [],
|
fieldGroups: [],
|
||||||
appendPolicies: [],
|
|
||||||
invoicePolicies: [],
|
invoicePolicies: [],
|
||||||
extensionAccess: [],
|
extensionAccess: [],
|
||||||
httpHosts: []
|
httpHosts: []
|
||||||
@@ -301,15 +244,6 @@
|
|||||||
item.invoicePolicies = publicInvoicePolicies(permission)
|
item.invoicePolicies = publicInvoicePolicies(permission)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (permission.id === 'ext.storage.append_public') {
|
|
||||||
item.appendPolicies = publicAppendPolicies(permission)
|
|
||||||
item.badges = item.appendPolicies.map(policy => ({
|
|
||||||
key:
|
|
||||||
policy.table + ':' + policy.sourceTable + ':' + policy.sourceIdField,
|
|
||||||
label: policy.table
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,11 +255,7 @@
|
|||||||
const hasReadWriteStorage =
|
const hasReadWriteStorage =
|
||||||
permissionsById.has('ext.storage.read') &&
|
permissionsById.has('ext.storage.read') &&
|
||||||
permissionsById.has('ext.storage.write')
|
permissionsById.has('ext.storage.write')
|
||||||
const hasWebsocket =
|
|
||||||
permissionsById.has('websocket.publish') &&
|
|
||||||
permissionsById.has('websocket.subscribe')
|
|
||||||
let addedReadWriteStorage = false
|
let addedReadWriteStorage = false
|
||||||
let addedWebsocket = false
|
|
||||||
|
|
||||||
return permissionList
|
return permissionList
|
||||||
.map((permission, index) => {
|
.map((permission, index) => {
|
||||||
@@ -344,21 +274,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
hasWebsocket &&
|
|
||||||
['websocket.publish', 'websocket.subscribe'].includes(permission.id)
|
|
||||||
) {
|
|
||||||
if (addedWebsocket) return null
|
|
||||||
addedWebsocket = true
|
|
||||||
return {
|
|
||||||
index,
|
|
||||||
orderId: 'websocket',
|
|
||||||
permissions: [
|
|
||||||
permissionsById.get('websocket.publish'),
|
|
||||||
permissionsById.get('websocket.subscribe')
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
orderId: permission.id,
|
orderId: permission.id,
|
||||||
@@ -412,9 +327,6 @@
|
|||||||
publicInvoicePolicySentence(policy) {
|
publicInvoicePolicySentence(policy) {
|
||||||
return `Invoices will be created using ${policy.walletField} from ${policy.table}.`
|
return `Invoices will be created using ${policy.walletField} from ${policy.table}.`
|
||||||
},
|
},
|
||||||
publicAppendPolicySentence(policy) {
|
|
||||||
return `${policy.table} rows can be appended for ${policy.sourceTable} using ${policy.sourceIdField}. Limit: ${policy.maxRowsPerSource} rows per source.`
|
|
||||||
},
|
|
||||||
permissionAccessLabel(access) {
|
permissionAccessLabel(access) {
|
||||||
const key = `extension_permission_access_${access}`
|
const key = `extension_permission_access_${access}`
|
||||||
const label = this.$t(key)
|
const label = this.$t(key)
|
||||||
|
|||||||
@@ -183,8 +183,7 @@ window.WasmExtensionComponent = {
|
|||||||
handleWindowMessage: null,
|
handleWindowMessage: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
loadId: 0,
|
loadId: 0,
|
||||||
paymentSubscriptions: new Map(),
|
paymentSubscriptions: new Map()
|
||||||
websocketSubscriptions: new Map()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
@@ -358,29 +357,6 @@ window.WasmExtensionComponent = {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
extensionRoute(path) {
|
|
||||||
let url
|
|
||||||
try {
|
|
||||||
url = new URL(String(path || ''), window.location.origin)
|
|
||||||
} catch (_error) {
|
|
||||||
throw new Error('Invalid extension route.')
|
|
||||||
}
|
|
||||||
if (url.origin !== window.location.origin) {
|
|
||||||
throw new Error('Extension route must stay on this server.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const basePath = `/ext/${encodeURIComponent(this.bridge.extensionId)}`
|
|
||||||
if (
|
|
||||||
url.pathname !== basePath &&
|
|
||||||
!url.pathname.startsWith(`${basePath}/`)
|
|
||||||
) {
|
|
||||||
throw new Error('Extension route must stay inside this extension.')
|
|
||||||
}
|
|
||||||
return `${url.pathname}${url.search}${url.hash}`
|
|
||||||
},
|
|
||||||
replaceExtensionRoute(message) {
|
|
||||||
return this.$router.replace(this.extensionRoute(message.path))
|
|
||||||
},
|
|
||||||
async callApi(message) {
|
async callApi(message) {
|
||||||
const method = String(message.method || 'GET').toUpperCase()
|
const method = String(message.method || 'GET').toUpperCase()
|
||||||
const path = String(message.path || '')
|
const path = String(message.path || '')
|
||||||
@@ -868,18 +844,6 @@ window.WasmExtensionComponent = {
|
|||||||
isPaymentHash(value) {
|
isPaymentHash(value) {
|
||||||
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)
|
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)
|
||||||
},
|
},
|
||||||
isWebsocketItemId(value) {
|
|
||||||
return (
|
|
||||||
typeof value === 'string' &&
|
|
||||||
/^[A-Za-z0-9][A-Za-z0-9:_-]{0,127}$/.test(value)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
scopedWebsocketItemId(itemId) {
|
|
||||||
if (!this.isWebsocketItemId(itemId)) {
|
|
||||||
throw new Error('Invalid websocket item ID.')
|
|
||||||
}
|
|
||||||
return `ext:${this.bridge.extensionId}:${itemId}`
|
|
||||||
},
|
|
||||||
websocketUrl(path) {
|
websocketUrl(path) {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
@@ -910,24 +874,8 @@ window.WasmExtensionComponent = {
|
|||||||
this.closePaymentSubscription(subscriptionId)
|
this.closePaymentSubscription(subscriptionId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
closeWebsocketSubscription(subscriptionId) {
|
|
||||||
const subscription = this.websocketSubscriptions.get(subscriptionId)
|
|
||||||
if (!subscription) return
|
|
||||||
this.websocketSubscriptions.delete(subscriptionId)
|
|
||||||
try {
|
|
||||||
subscription.socket.close()
|
|
||||||
} catch (_error) {}
|
|
||||||
},
|
|
||||||
closeWebsocketSubscriptions() {
|
|
||||||
for (const subscriptionId of Array.from(
|
|
||||||
this.websocketSubscriptions.keys()
|
|
||||||
)) {
|
|
||||||
this.closeWebsocketSubscription(subscriptionId)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
closeBridgePort() {
|
closeBridgePort() {
|
||||||
this.closePaymentSubscriptions()
|
this.closePaymentSubscriptions()
|
||||||
this.closeWebsocketSubscriptions()
|
|
||||||
this.bridgePort?.close()
|
this.bridgePort?.close()
|
||||||
this.bridgePort = null
|
this.bridgePort = null
|
||||||
},
|
},
|
||||||
@@ -989,55 +937,6 @@ window.WasmExtensionComponent = {
|
|||||||
this.paymentSubscriptions.delete(subscriptionId)
|
this.paymentSubscriptions.delete(subscriptionId)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
subscribeWebsocket(message) {
|
|
||||||
if (!this.hasBridgePermission('websocket.subscribe')) {
|
|
||||||
throw new Error('Extension is missing websocket subscribe permission.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscriptionId = String(message.subscriptionId || '')
|
|
||||||
const itemId = String(message.itemId || '')
|
|
||||||
|
|
||||||
if (
|
|
||||||
!subscriptionId ||
|
|
||||||
subscriptionId.length > 128 ||
|
|
||||||
!this.isWebsocketItemId(itemId)
|
|
||||||
) {
|
|
||||||
throw new Error('Invalid websocket subscription.')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.closeWebsocketSubscription(subscriptionId)
|
|
||||||
|
|
||||||
const scopedItemId = this.scopedWebsocketItemId(itemId)
|
|
||||||
const socket = new WebSocket(
|
|
||||||
this.websocketUrl(`/api/v1/ws/${encodeURIComponent(scopedItemId)}`)
|
|
||||||
)
|
|
||||||
this.websocketSubscriptions.set(subscriptionId, {itemId, socket})
|
|
||||||
|
|
||||||
socket.addEventListener('message', event => {
|
|
||||||
let data = event.data
|
|
||||||
try {
|
|
||||||
data = JSON.parse(event.data)
|
|
||||||
} catch (_error) {}
|
|
||||||
|
|
||||||
this.sendBridgeEvent({
|
|
||||||
event: 'websocket.message',
|
|
||||||
subscriptionId,
|
|
||||||
itemId,
|
|
||||||
data
|
|
||||||
})
|
|
||||||
})
|
|
||||||
socket.addEventListener('error', () => {
|
|
||||||
this.sendBridgeEvent({
|
|
||||||
event: 'websocket.error',
|
|
||||||
subscriptionId,
|
|
||||||
itemId
|
|
||||||
})
|
|
||||||
this.closeWebsocketSubscription(subscriptionId)
|
|
||||||
})
|
|
||||||
socket.addEventListener('close', () => {
|
|
||||||
this.websocketSubscriptions.delete(subscriptionId)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async handleBridgeRequest(message, reply) {
|
async handleBridgeRequest(message, reply) {
|
||||||
if (!message || message.type !== 'lnbits-extension:request') return
|
if (!message || message.type !== 'lnbits-extension:request') return
|
||||||
|
|
||||||
@@ -1067,15 +966,6 @@ window.WasmExtensionComponent = {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.action === 'navigation.replace') {
|
|
||||||
await this.replaceExtensionRoute(message)
|
|
||||||
this.sendResponse(reply, message.id, {
|
|
||||||
ok: true,
|
|
||||||
data: {ok: true}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.action === 'ui.scan_qr') {
|
if (message.action === 'ui.scan_qr') {
|
||||||
this.sendResponse(reply, message.id, {
|
this.sendResponse(reply, message.id, {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -1126,24 +1016,6 @@ window.WasmExtensionComponent = {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.action === 'websocket.subscribe') {
|
|
||||||
this.subscribeWebsocket(message)
|
|
||||||
this.sendResponse(reply, message.id, {
|
|
||||||
ok: true,
|
|
||||||
data: {ok: true}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.action === 'websocket.unsubscribe') {
|
|
||||||
this.closeWebsocketSubscription(String(message.subscriptionId || ''))
|
|
||||||
this.sendResponse(reply, message.id, {
|
|
||||||
ok: true,
|
|
||||||
data: {ok: true}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Unknown extension bridge action.')
|
throw new Error('Unknown extension bridge action.')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.sendResponse(reply, message.id, {
|
this.sendResponse(reply, message.id, {
|
||||||
|
|||||||
@@ -9,31 +9,28 @@
|
|||||||
>
|
>
|
||||||
<template v-slot:header>
|
<template v-slot:header>
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<div class="row items-center q-col-gutter-x-md q-row-gutter-sm">
|
<q-item-label class="text-weight-medium">
|
||||||
<q-item-label
|
<span v-text="permission.label"></span>
|
||||||
class="text-weight-medium col-auto"
|
</q-item-label>
|
||||||
style="max-width: 100%"
|
</q-item-section>
|
||||||
>
|
<q-item-section
|
||||||
<span v-text="permission.label"></span>
|
v-if="permission.risk.level !== 'low' || permission.badges.length"
|
||||||
</q-item-label>
|
side
|
||||||
<div
|
top
|
||||||
v-if="permission.risk.level !== 'low' || permission.badges.length"
|
>
|
||||||
class="row items-center q-gutter-xs col-auto q-mb-xs"
|
<div class="row items-center justify-end q-gutter-xs">
|
||||||
style="max-width: 100%"
|
<q-badge
|
||||||
>
|
v-for="badge of permission.badges"
|
||||||
<q-badge
|
:key="badge.key"
|
||||||
v-for="badge of permission.badges"
|
outline
|
||||||
:key="badge.key"
|
color="primary"
|
||||||
outline
|
v-text="badge.label"
|
||||||
color="primary"
|
></q-badge>
|
||||||
v-text="badge.label"
|
<q-badge
|
||||||
></q-badge>
|
v-if="permission.risk.level !== 'low'"
|
||||||
<q-badge
|
:color="permission.risk.color"
|
||||||
v-if="permission.risk.level !== 'low'"
|
v-text="permission.risk.label"
|
||||||
:color="permission.risk.color"
|
></q-badge>
|
||||||
v-text="permission.risk.label"
|
|
||||||
></q-badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</template>
|
</template>
|
||||||
@@ -60,24 +57,7 @@
|
|||||||
></p>
|
></p>
|
||||||
<ul v-if="permission.fieldGroups.length" class="q-my-sm q-pl-md">
|
<ul v-if="permission.fieldGroups.length" class="q-my-sm q-pl-md">
|
||||||
<li v-for="group of permission.fieldGroups" :key="group.table">
|
<li v-for="group of permission.fieldGroups" :key="group.table">
|
||||||
<div class="row items-center q-gutter-xs">
|
<span v-text="group.table"></span>
|
||||||
<span v-text="group.table"></span>
|
|
||||||
<template v-if="group.sourceIdField">
|
|
||||||
<q-badge
|
|
||||||
color="warning"
|
|
||||||
text-color="dark"
|
|
||||||
v-text="group.sourceIdField"
|
|
||||||
></q-badge>
|
|
||||||
<span
|
|
||||||
class="text-caption text-grey"
|
|
||||||
v-text="
|
|
||||||
$t(
|
|
||||||
'extension_permission_ext_storage_read_public_source_required'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
></span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<ul v-if="group.fields.length" class="q-pl-md">
|
<ul v-if="group.fields.length" class="q-pl-md">
|
||||||
<li
|
<li
|
||||||
v-for="field of group.fields"
|
v-for="field of group.fields"
|
||||||
@@ -87,35 +67,6 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div v-if="permission.appendPolicies.length" class="q-mt-sm">
|
|
||||||
<div
|
|
||||||
class="text-caption text-grey"
|
|
||||||
v-text="
|
|
||||||
$t('extension_permission_ext_storage_append_public_sources')
|
|
||||||
"
|
|
||||||
></div>
|
|
||||||
<ul class="q-my-sm q-pl-md">
|
|
||||||
<li
|
|
||||||
v-for="policy of permission.appendPolicies"
|
|
||||||
:key="
|
|
||||||
policy.table +
|
|
||||||
':' +
|
|
||||||
policy.sourceTable +
|
|
||||||
':' +
|
|
||||||
policy.sourceIdField
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<span v-text="publicAppendPolicySentence(policy)"></span>
|
|
||||||
<ul v-if="policy.allowedFields.length" class="q-pl-md">
|
|
||||||
<li
|
|
||||||
v-for="field of policy.allowedFields"
|
|
||||||
:key="policy.table + ':' + field"
|
|
||||||
v-text="field"
|
|
||||||
></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div v-if="permission.extensionAccess.length" class="q-mt-sm">
|
<div v-if="permission.extensionAccess.length" class="q-mt-sm">
|
||||||
<div
|
<div
|
||||||
class="text-caption text-grey"
|
class="text-caption text-grey"
|
||||||
|
|||||||
+35
-3
@@ -1,4 +1,4 @@
|
|||||||
from datetime import date, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -9,9 +9,41 @@ from lnbits.core.crud import (
|
|||||||
get_wallet_for_key,
|
get_wallet_for_key,
|
||||||
)
|
)
|
||||||
from lnbits.core.crud.payments import get_payment
|
from lnbits.core.crud.payments import get_payment
|
||||||
from lnbits.core.models import CreateInvoice
|
from lnbits.core.models import CreateInvoice, PaymentFilters
|
||||||
from lnbits.core.services.payments import create_wallet_invoice
|
from lnbits.core.services.payments import create_wallet_invoice
|
||||||
from lnbits.db import POSTGRES
|
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
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|||||||
@@ -111,6 +111,36 @@ async def test_update_pending_payment_and_bulk_pending_updates(mocker: MockerFix
|
|||||||
assert bulk_statuses == {PaymentState.FAILED, PaymentState.SUCCESS}
|
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
|
@pytest.mark.anyio
|
||||||
async def test_check_pending_payments_skips_voidwallet_and_updates_recent_items(
|
async def test_check_pending_payments_skips_voidwallet_and_updates_recent_items(
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
@@ -451,6 +481,8 @@ async def _create_payment(
|
|||||||
payment_hash: str | None = None,
|
payment_hash: str | None = None,
|
||||||
fee: int = 0,
|
fee: int = 0,
|
||||||
time: datetime | None = None,
|
time: datetime | None = None,
|
||||||
|
expiry: datetime | None = None,
|
||||||
|
labels: list[str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
checking_id = checking_id or f"checking_{uuid4().hex[:8]}"
|
checking_id = checking_id or f"checking_{uuid4().hex[:8]}"
|
||||||
payment_hash = payment_hash or uuid4().hex
|
payment_hash = payment_hash or uuid4().hex
|
||||||
@@ -462,7 +494,9 @@ async def _create_payment(
|
|||||||
bolt11=f"bolt11-{checking_id}",
|
bolt11=f"bolt11-{checking_id}",
|
||||||
amount_msat=amount_msat,
|
amount_msat=amount_msat,
|
||||||
memo="memo",
|
memo="memo",
|
||||||
|
expiry=expiry,
|
||||||
fee=fee,
|
fee=fee,
|
||||||
|
labels=labels,
|
||||||
),
|
),
|
||||||
status=status,
|
status=status,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ def test_wasm_frontend_assets_are_registered_in_component_bundle():
|
|||||||
assert "js/components/admin/lnbits-admin-wasm-limit-config.js" in components
|
assert "js/components/admin/lnbits-admin-wasm-limit-config.js" in components
|
||||||
|
|
||||||
|
|
||||||
def test_wasm_frontend_bridge_restricts_api_routes_and_realtime_actions():
|
def test_wasm_frontend_bridge_restricts_api_routes_and_payment_actions():
|
||||||
bridge = (ROOT / "lnbits/static/js/wasm-extension-component.js").read_text(
|
bridge = (ROOT / "lnbits/static/js/wasm-extension-component.js").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
@@ -22,15 +22,8 @@ def test_wasm_frontend_bridge_restricts_api_routes_and_realtime_actions():
|
|||||||
assert "allowedApiRoute(method, path)" in bridge
|
assert "allowedApiRoute(method, path)" in bridge
|
||||||
assert "url.origin !== window.location.origin" in bridge
|
assert "url.origin !== window.location.origin" in bridge
|
||||||
assert "Extension API route is not allowed." in bridge
|
assert "Extension API route is not allowed." in bridge
|
||||||
assert "extensionRoute(path)" in bridge
|
|
||||||
assert "Extension route must stay inside this extension." in bridge
|
|
||||||
assert "message.action === 'payment.subscribe'" in bridge
|
assert "message.action === 'payment.subscribe'" in bridge
|
||||||
assert "message.action === 'payment.unsubscribe'" in bridge
|
assert "message.action === 'payment.unsubscribe'" in bridge
|
||||||
assert "message.action === 'websocket.subscribe'" in bridge
|
|
||||||
assert "message.action === 'websocket.unsubscribe'" in bridge
|
|
||||||
assert "message.action === 'navigation.replace'" in bridge
|
|
||||||
assert "hasBridgePermission('websocket.subscribe')" in bridge
|
|
||||||
assert "ext:${this.bridge.extensionId}:${itemId}" in bridge
|
|
||||||
assert "message.action === 'ui.scan_qr'" in bridge
|
assert "message.action === 'ui.scan_qr'" in bridge
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ from lnbits.core.wasm_ext.api.models import (
|
|||||||
CreateInvoicePublicRequest,
|
CreateInvoicePublicRequest,
|
||||||
EmptyRequest,
|
EmptyRequest,
|
||||||
PayInvoiceRequest,
|
PayInvoiceRequest,
|
||||||
StorageAppendPublicRequest,
|
|
||||||
StorageGetRequest,
|
StorageGetRequest,
|
||||||
StoragePublicPaginatedRequest,
|
|
||||||
WalletBalanceRequest,
|
WalletBalanceRequest,
|
||||||
WebsocketPublishRequest,
|
|
||||||
)
|
)
|
||||||
from lnbits.exceptions import PaymentError
|
from lnbits.exceptions import PaymentError
|
||||||
from lnbits.helpers import sha256s
|
from lnbits.helpers import sha256s
|
||||||
@@ -51,209 +48,6 @@ async def test_host_api_filters_public_storage_fields(mocker: MockerFixture):
|
|||||||
storage_mock.assert_awaited_once_with("demoext", "tips", "tip-1")
|
storage_mock.assert_awaited_once_with("demoext", "tips", "tip-1")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_host_api_filters_public_paginated_storage_rows(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
):
|
|
||||||
storage_mock = mocker.patch(
|
|
||||||
"lnbits.core.wasm_ext.api.host.storage_get_public_paginated_rows",
|
|
||||||
mocker.AsyncMock(
|
|
||||||
return_value={
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"id": "message-1",
|
|
||||||
"thread_id": "thread-1",
|
|
||||||
"message": "Hello",
|
|
||||||
"admin_note": "secret",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 1,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
api = ExtensionHostAPI(
|
|
||||||
"demoext",
|
|
||||||
[
|
|
||||||
ExtensionPermission(
|
|
||||||
id="ext.storage.read_public",
|
|
||||||
policies=[
|
|
||||||
{
|
|
||||||
"table_name": "messages",
|
|
||||||
"source_id_field": "thread_id",
|
|
||||||
"public_fields": ["id", "thread_id", "message"],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await api.storage_get_public_paginated(
|
|
||||||
StoragePublicPaginatedRequest(
|
|
||||||
table="messages",
|
|
||||||
filters={},
|
|
||||||
search="hello",
|
|
||||||
search_fields=["message"],
|
|
||||||
sort_by="id",
|
|
||||||
descending=False,
|
|
||||||
limit=25,
|
|
||||||
offset=0,
|
|
||||||
source_id="thread-1",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert json.loads(response.rows_json) == [
|
|
||||||
{"id": "message-1", "thread_id": "thread-1", "message": "Hello"}
|
|
||||||
]
|
|
||||||
assert response.total == 1
|
|
||||||
storage_mock.assert_awaited_once_with(
|
|
||||||
"demoext",
|
|
||||||
"messages",
|
|
||||||
{"thread_id": "thread-1"},
|
|
||||||
search="hello",
|
|
||||||
search_fields=["message"],
|
|
||||||
sort_by="id",
|
|
||||||
descending=False,
|
|
||||||
limit=25,
|
|
||||||
offset=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_host_api_public_paginated_storage_rejects_private_query_fields():
|
|
||||||
api = ExtensionHostAPI(
|
|
||||||
"demoext",
|
|
||||||
[
|
|
||||||
ExtensionPermission(
|
|
||||||
id="ext.storage.read_public",
|
|
||||||
policies=[
|
|
||||||
{
|
|
||||||
"table_name": "messages",
|
|
||||||
"source_id_field": "thread_id",
|
|
||||||
"public_fields": ["id", "message"],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(PermissionError, match="non-public fields"):
|
|
||||||
await api.storage_get_public_paginated(
|
|
||||||
StoragePublicPaginatedRequest(
|
|
||||||
table="messages",
|
|
||||||
filters={"admin_note": "secret"},
|
|
||||||
search=None,
|
|
||||||
search_fields=[],
|
|
||||||
sort_by=None,
|
|
||||||
descending=False,
|
|
||||||
limit=25,
|
|
||||||
offset=0,
|
|
||||||
source_id="thread-1",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_host_api_public_paginated_storage_requires_source_policy():
|
|
||||||
api = ExtensionHostAPI(
|
|
||||||
"demoext",
|
|
||||||
[
|
|
||||||
ExtensionPermission(
|
|
||||||
id="ext.storage.read_public",
|
|
||||||
policies=[
|
|
||||||
{
|
|
||||||
"table_name": "messages",
|
|
||||||
"public_fields": ["id", "message"],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(PermissionError, match="source ID field policy"):
|
|
||||||
await api.storage_get_public_paginated(
|
|
||||||
StoragePublicPaginatedRequest(
|
|
||||||
table="messages",
|
|
||||||
filters={},
|
|
||||||
search=None,
|
|
||||||
search_fields=[],
|
|
||||||
sort_by=None,
|
|
||||||
descending=False,
|
|
||||||
limit=25,
|
|
||||||
offset=0,
|
|
||||||
source_id="thread-1",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_host_api_public_paginated_storage_rejects_conflicting_source_filter():
|
|
||||||
api = ExtensionHostAPI(
|
|
||||||
"demoext",
|
|
||||||
[
|
|
||||||
ExtensionPermission(
|
|
||||||
id="ext.storage.read_public",
|
|
||||||
policies=[
|
|
||||||
{
|
|
||||||
"table_name": "messages",
|
|
||||||
"source_id_field": "thread_id",
|
|
||||||
"public_fields": ["id", "message"],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(PermissionError, match="does not match source_id"):
|
|
||||||
await api.storage_get_public_paginated(
|
|
||||||
StoragePublicPaginatedRequest(
|
|
||||||
table="messages",
|
|
||||||
filters={"thread_id": "thread-2"},
|
|
||||||
search=None,
|
|
||||||
search_fields=[],
|
|
||||||
sort_by=None,
|
|
||||||
descending=False,
|
|
||||||
limit=25,
|
|
||||||
offset=0,
|
|
||||||
source_id="thread-1",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_host_api_websocket_publish_scopes_item_id(mocker: MockerFixture):
|
|
||||||
send_mock = mocker.patch(
|
|
||||||
"lnbits.core.services.websocket_manager.send",
|
|
||||||
mocker.AsyncMock(),
|
|
||||||
)
|
|
||||||
api = ExtensionHostAPI("demoext", ["websocket.publish"])
|
|
||||||
|
|
||||||
response = await api.websocket_publish(
|
|
||||||
WebsocketPublishRequest(
|
|
||||||
item_id="conversation:abc_123",
|
|
||||||
data={"message": "Hello"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.sent is True
|
|
||||||
send_mock.assert_awaited_once_with(
|
|
||||||
"ext:demoext:conversation:abc_123",
|
|
||||||
'{"message":"Hello"}',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_host_api_websocket_publish_rejects_invalid_item_id():
|
|
||||||
api = ExtensionHostAPI("demoext", ["websocket.publish"])
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="item ID"):
|
|
||||||
await api.websocket_publish(
|
|
||||||
WebsocketPublishRequest(
|
|
||||||
item_id="../other",
|
|
||||||
data={"message": "Hello"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_host_api_storage_requires_owner_context_and_uses_user_hash(
|
async def test_host_api_storage_requires_owner_context_and_uses_user_hash(
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
@@ -283,136 +77,6 @@ async def test_host_api_storage_requires_owner_context_and_uses_user_hash(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_host_api_public_append_uses_source_owner_and_allowed_fields(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
):
|
|
||||||
owner_mock = mocker.patch(
|
|
||||||
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
|
|
||||||
mocker.AsyncMock(return_value="owner-1"),
|
|
||||||
)
|
|
||||||
count_mock = mocker.patch(
|
|
||||||
"lnbits.core.wasm_ext.api.host.storage_count_rows",
|
|
||||||
mocker.AsyncMock(return_value=0),
|
|
||||||
)
|
|
||||||
append_mock = mocker.patch(
|
|
||||||
"lnbits.core.wasm_ext.api.host.storage_append_public_row",
|
|
||||||
mocker.AsyncMock(return_value="message-1"),
|
|
||||||
)
|
|
||||||
api = ExtensionHostAPI(
|
|
||||||
"demoext",
|
|
||||||
[
|
|
||||||
ExtensionPermission(
|
|
||||||
id="ext.storage.append_public",
|
|
||||||
policies=[
|
|
||||||
{
|
|
||||||
"table": "messages",
|
|
||||||
"source_table": "threads",
|
|
||||||
"source_id_field": "thread_id",
|
|
||||||
"allowed_fields": ["name", "message"],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await api.storage_append_public(
|
|
||||||
StorageAppendPublicRequest(
|
|
||||||
table="messages",
|
|
||||||
source_id="thread-1",
|
|
||||||
data={"name": "Alice", "message": "Hello"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.id == "message-1"
|
|
||||||
owner_mock.assert_awaited_once_with("demoext", "threads", "thread-1")
|
|
||||||
count_mock.assert_awaited_once_with(
|
|
||||||
"demoext",
|
|
||||||
"messages",
|
|
||||||
{"thread_id": "thread-1"},
|
|
||||||
owner_id="owner-1",
|
|
||||||
)
|
|
||||||
append_mock.assert_awaited_once_with(
|
|
||||||
"demoext",
|
|
||||||
"messages",
|
|
||||||
{"name": "Alice", "message": "Hello", "thread_id": "thread-1"},
|
|
||||||
"owner-1",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_host_api_public_append_rejects_disallowed_fields(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
):
|
|
||||||
mocker.patch(
|
|
||||||
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
|
|
||||||
mocker.AsyncMock(return_value="owner-1"),
|
|
||||||
)
|
|
||||||
api = ExtensionHostAPI(
|
|
||||||
"demoext",
|
|
||||||
[
|
|
||||||
ExtensionPermission(
|
|
||||||
id="ext.storage.append_public",
|
|
||||||
policies=[
|
|
||||||
{
|
|
||||||
"table": "messages",
|
|
||||||
"source_table": "threads",
|
|
||||||
"source_id_field": "thread_id",
|
|
||||||
"allowed_fields": ["message"],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(PermissionError, match="disallowed fields"):
|
|
||||||
await api.storage_append_public(
|
|
||||||
StorageAppendPublicRequest(
|
|
||||||
table="messages",
|
|
||||||
source_id="thread-1",
|
|
||||||
data={"message": "Hello", "admin": True},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_host_api_public_append_enforces_row_limit(mocker: MockerFixture):
|
|
||||||
mocker.patch(
|
|
||||||
"lnbits.core.wasm_ext.api.host.storage_get_row_owner_id",
|
|
||||||
mocker.AsyncMock(return_value="owner-1"),
|
|
||||||
)
|
|
||||||
mocker.patch(
|
|
||||||
"lnbits.core.wasm_ext.api.host.storage_count_rows",
|
|
||||||
mocker.AsyncMock(return_value=1),
|
|
||||||
)
|
|
||||||
api = ExtensionHostAPI(
|
|
||||||
"demoext",
|
|
||||||
[
|
|
||||||
ExtensionPermission(
|
|
||||||
id="ext.storage.append_public",
|
|
||||||
policies=[
|
|
||||||
{
|
|
||||||
"table": "messages",
|
|
||||||
"source_table": "threads",
|
|
||||||
"source_id_field": "thread_id",
|
|
||||||
"allowed_fields": ["message"],
|
|
||||||
"max_rows_per_source": 1,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(PermissionError, match="limit reached"):
|
|
||||||
await api.storage_append_public(
|
|
||||||
StorageAppendPublicRequest(
|
|
||||||
table="messages",
|
|
||||||
source_id="thread-1",
|
|
||||||
data={"message": "Hello"},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_host_api_wallet_methods_require_permissions_and_user_wallets(
|
async def test_host_api_wallet_methods_require_permissions_and_user_wallets(
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
|
|||||||
"policies": [
|
"policies": [
|
||||||
{
|
{
|
||||||
"table_name": "tip_jars",
|
"table_name": "tip_jars",
|
||||||
"source_id_field": "wallet_id",
|
|
||||||
"public_fields": ["id", "title", "description"],
|
"public_fields": ["id", "title", "description"],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -81,7 +80,6 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
|
|||||||
policies=[
|
policies=[
|
||||||
{
|
{
|
||||||
"table_name": "tip_jars",
|
"table_name": "tip_jars",
|
||||||
"source_id_field": "wallet_id",
|
|
||||||
"public_fields": ["id", "title"],
|
"public_fields": ["id", "title"],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -97,7 +95,6 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
|
|||||||
policies=[
|
policies=[
|
||||||
{
|
{
|
||||||
"table_name": "tip_jars",
|
"table_name": "tip_jars",
|
||||||
"source_id_field": "wallet_id",
|
|
||||||
"public_fields": ["id", "title"],
|
"public_fields": ["id", "title"],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -105,140 +102,6 @@ def test_validate_wasm_permissions_stores_narrower_policy_grant():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_validate_wasm_permissions_rejects_public_read_source_field_omission():
|
|
||||||
ext_info = make_installable_extension("demoext")
|
|
||||||
extension_config = _wasm_config(
|
|
||||||
"demoext",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "ext.storage.read_public",
|
|
||||||
"policies": [
|
|
||||||
{
|
|
||||||
"table_name": "messages",
|
|
||||||
"source_id_field": "conversation_id",
|
|
||||||
"public_fields": ["id", "body"],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="broader policies"):
|
|
||||||
validate_wasm_extension_permissions(
|
|
||||||
ext_info,
|
|
||||||
[
|
|
||||||
ExtensionPermission(
|
|
||||||
id="ext.storage.read_public",
|
|
||||||
policies=[
|
|
||||||
{
|
|
||||||
"table_name": "messages",
|
|
||||||
"public_fields": ["id", "body"],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
extension_config,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_wasm_permissions_allows_narrower_public_append_grant():
|
|
||||||
ext_info = make_installable_extension("demoext")
|
|
||||||
extension_config = _wasm_config(
|
|
||||||
"demoext",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "ext.storage.append_public",
|
|
||||||
"description": "Append public messages.",
|
|
||||||
"policies": [
|
|
||||||
{
|
|
||||||
"table": "messages",
|
|
||||||
"source_table": "threads",
|
|
||||||
"source_id_field": "thread_id",
|
|
||||||
"allowed_fields": ["name", "message"],
|
|
||||||
"max_rows_per_source": 100,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
permissions = validate_wasm_extension_permissions(
|
|
||||||
ext_info,
|
|
||||||
[
|
|
||||||
ExtensionPermission(
|
|
||||||
id="ext.storage.append_public",
|
|
||||||
policies=[
|
|
||||||
{
|
|
||||||
"table": "messages",
|
|
||||||
"source_table": "threads",
|
|
||||||
"source_id_field": "thread_id",
|
|
||||||
"allowed_fields": ["message"],
|
|
||||||
"max_rows_per_source": 50,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
extension_config,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert permissions == [
|
|
||||||
ExtensionPermission(
|
|
||||||
id="ext.storage.append_public",
|
|
||||||
description="Append public messages.",
|
|
||||||
policies=[
|
|
||||||
{
|
|
||||||
"table": "messages",
|
|
||||||
"source_table": "threads",
|
|
||||||
"source_id_field": "thread_id",
|
|
||||||
"allowed_fields": ["message"],
|
|
||||||
"max_rows_per_source": 50,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_wasm_permissions_rejects_broader_public_append_grant():
|
|
||||||
ext_info = make_installable_extension("demoext")
|
|
||||||
extension_config = _wasm_config(
|
|
||||||
"demoext",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "ext.storage.append_public",
|
|
||||||
"policies": [
|
|
||||||
{
|
|
||||||
"table": "messages",
|
|
||||||
"source_table": "threads",
|
|
||||||
"source_id_field": "thread_id",
|
|
||||||
"allowed_fields": ["message"],
|
|
||||||
"max_rows_per_source": 100,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="broader policies"):
|
|
||||||
validate_wasm_extension_permissions(
|
|
||||||
ext_info,
|
|
||||||
[
|
|
||||||
ExtensionPermission(
|
|
||||||
id="ext.storage.append_public",
|
|
||||||
policies=[
|
|
||||||
{
|
|
||||||
"table": "messages",
|
|
||||||
"source_table": "threads",
|
|
||||||
"source_id_field": "thread_id",
|
|
||||||
"allowed_fields": ["message", "admin"],
|
|
||||||
"max_rows_per_source": 101,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
extension_config,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_wasm_permissions_rejects_broader_extension_api_access():
|
def test_validate_wasm_permissions_rejects_broader_extension_api_access():
|
||||||
ext_info = make_installable_extension("demoext")
|
ext_info = make_installable_extension("demoext")
|
||||||
extension_config = _wasm_config(
|
extension_config = _wasm_config(
|
||||||
@@ -308,29 +171,6 @@ def test_validate_wasm_permissions_allows_wallet_payments_watch_permission():
|
|||||||
) == [ExtensionPermission(id="wallet.payments.watch")]
|
) == [ExtensionPermission(id="wallet.payments.watch")]
|
||||||
|
|
||||||
|
|
||||||
def test_validate_wasm_permissions_allows_websocket_permissions():
|
|
||||||
ext_info = make_installable_extension("demoext")
|
|
||||||
extension_config = _wasm_config(
|
|
||||||
"demoext",
|
|
||||||
[
|
|
||||||
{"id": "websocket.publish"},
|
|
||||||
{"id": "websocket.subscribe"},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert validate_wasm_extension_permissions(
|
|
||||||
ext_info,
|
|
||||||
[
|
|
||||||
ExtensionPermission(id="websocket.publish"),
|
|
||||||
ExtensionPermission(id="websocket.subscribe"),
|
|
||||||
],
|
|
||||||
extension_config,
|
|
||||||
) == [
|
|
||||||
ExtensionPermission(id="websocket.publish"),
|
|
||||||
ExtensionPermission(id="websocket.subscribe"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_background_payment_grant_lookup_and_policy_coverage():
|
def test_background_payment_grant_lookup_and_policy_coverage():
|
||||||
permissions = {
|
permissions = {
|
||||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
|
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION: [
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import json
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any, cast
|
from typing import cast
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -22,8 +22,6 @@ from lnbits.core.wasm_ext.storage import crud as storage_crud
|
|||||||
from lnbits.core.wasm_ext.storage.crud import (
|
from lnbits.core.wasm_ext.storage.crud import (
|
||||||
OWNER_ID_FIELD,
|
OWNER_ID_FIELD,
|
||||||
migrate_wasm_extension_database,
|
migrate_wasm_extension_database,
|
||||||
storage_append_public_row,
|
|
||||||
storage_count_rows,
|
|
||||||
storage_delete_row,
|
storage_delete_row,
|
||||||
storage_get_paginated_rows,
|
storage_get_paginated_rows,
|
||||||
storage_get_public_row,
|
storage_get_public_row,
|
||||||
@@ -297,59 +295,6 @@ async def test_wasm_storage_migration_and_owner_scoped_crud(
|
|||||||
assert deleted is None
|
assert deleted is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_wasm_storage_public_append_generates_id_and_counts_by_owner(
|
|
||||||
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)
|
|
||||||
|
|
||||||
await migrate_wasm_extension_database(make_installable_extension(ext_id))
|
|
||||||
await storage_set_row(
|
|
||||||
ext_id,
|
|
||||||
"threads",
|
|
||||||
{"id": "thread-1", "title": "Support"},
|
|
||||||
"owner-1",
|
|
||||||
)
|
|
||||||
message_id = await storage_append_public_row(
|
|
||||||
ext_id,
|
|
||||||
"messages",
|
|
||||||
{"thread_id": "thread-1", "message": "Hello"},
|
|
||||||
"owner-1",
|
|
||||||
)
|
|
||||||
owner_count = await storage_count_rows(
|
|
||||||
ext_id,
|
|
||||||
"messages",
|
|
||||||
{"thread_id": "thread-1"},
|
|
||||||
owner_id="owner-1",
|
|
||||||
)
|
|
||||||
other_owner_count = await storage_count_rows(
|
|
||||||
ext_id,
|
|
||||||
"messages",
|
|
||||||
{"thread_id": "thread-1"},
|
|
||||||
owner_id="owner-2",
|
|
||||||
)
|
|
||||||
message = await storage_get_row(ext_id, "messages", message_id, "owner-1")
|
|
||||||
finally:
|
|
||||||
settings.lnbits_extensions_path = original_extensions_path
|
|
||||||
settings.lnbits_data_folder = original_data_folder
|
|
||||||
|
|
||||||
assert message_id
|
|
||||||
assert owner_count == 1
|
|
||||||
assert other_owner_count == 0
|
|
||||||
assert message is not None
|
|
||||||
assert message["id"] == message_id
|
|
||||||
assert message["thread_id"] == "thread-1"
|
|
||||||
assert message["message"] == "Hello"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
|
async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
@@ -433,7 +378,7 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
|
|||||||
storage_dir = ext_dir / "storage"
|
storage_dir = ext_dir / "storage"
|
||||||
migrations_dir = storage_dir / "migrations"
|
migrations_dir = storage_dir / "migrations"
|
||||||
migrations_dir.mkdir(parents=True)
|
migrations_dir.mkdir(parents=True)
|
||||||
schema: dict[str, Any] = {
|
schema = {
|
||||||
"tables": {
|
"tables": {
|
||||||
"notes": {
|
"notes": {
|
||||||
"fields": [
|
"fields": [
|
||||||
@@ -444,20 +389,7 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
|
|||||||
{"name": "tags", "type": "string", "list": True},
|
{"name": "tags", "type": "string", "list": True},
|
||||||
{"name": "created_at", "type": "datetime"},
|
{"name": "created_at", "type": "datetime"},
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
"threads": {
|
|
||||||
"fields": [
|
|
||||||
{"name": "id", "type": "string"},
|
|
||||||
{"name": "title", "type": "string"},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"messages": {
|
|
||||||
"fields": [
|
|
||||||
{"name": "id", "type": "string"},
|
|
||||||
{"name": "thread_id", "type": "string"},
|
|
||||||
{"name": "message", "type": "string"},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
migration = {
|
migration = {
|
||||||
@@ -466,17 +398,7 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
|
|||||||
"op": "create_table",
|
"op": "create_table",
|
||||||
"table": "notes",
|
"table": "notes",
|
||||||
"fields": schema["tables"]["notes"]["fields"],
|
"fields": schema["tables"]["notes"]["fields"],
|
||||||
},
|
}
|
||||||
{
|
|
||||||
"op": "create_table",
|
|
||||||
"table": "threads",
|
|
||||||
"fields": schema["tables"]["threads"]["fields"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"op": "create_table",
|
|
||||||
"table": "messages",
|
|
||||||
"fields": schema["tables"]["messages"]["fields"],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
(storage_dir / "schema.json").write_text(json.dumps(schema), encoding="utf-8")
|
(storage_dir / "schema.json").write_text(json.dumps(schema), encoding="utf-8")
|
||||||
|
|||||||
Reference in New Issue
Block a user