diff --git a/lnbits/core/wasm_ext/api/host.py b/lnbits/core/wasm_ext/api/host.py index ac9a0075b..628913877 100644 --- a/lnbits/core/wasm_ext/api/host.py +++ b/lnbits/core/wasm_ext/api/host.py @@ -11,10 +11,14 @@ from lnbits.helpers import sha256s from ..client.extensions import send_extension_api_request from ..storage.crud import ( + OWNER_ID_FIELD, + storage_append_public_row, + storage_count_rows, storage_delete_row, storage_get_paginated_rows, storage_get_public_row, storage_get_row, + storage_get_row_owner_id, storage_set_row, ) from .background_payments import ( @@ -39,6 +43,8 @@ from .models import ( PayLnurlRequest, RandomIdRequest, RandomIdResponse, + StorageAppendPublicRequest, + StorageAppendPublicResponse, StorageDeleteRequest, StorageDeleteResponse, StorageGetRequest, @@ -54,6 +60,7 @@ from .models import ( from .registry import extension_api_method logger = logging.getLogger("lnbits.extensions") +PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000 class ExtensionHostAPI: @@ -129,6 +136,45 @@ class ExtensionHostAPI: # todo: check public fields filtering 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( method_id="storage.set", namespace="storage", @@ -652,6 +698,98 @@ class ExtensionHostAPI: raise PermissionError(f"Storage table '{table}' is not publicly readable.") + 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]]: policies = self.permission_policies.get("wallet.create_invoice_public") if not isinstance(policies, list) or not policies: diff --git a/lnbits/core/wasm_ext/api/models.py b/lnbits/core/wasm_ext/api/models.py index 028ef3ea0..1de683bdd 100644 --- a/lnbits/core/wasm_ext/api/models.py +++ b/lnbits/core/wasm_ext/api/models.py @@ -67,6 +67,23 @@ class StorageSetResponse(BaseModel): 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): table: str = Field(..., min_length=1, max_length=128) filters: dict[str, Any] = Field(default_factory=dict) diff --git a/lnbits/core/wasm_ext/api/permissions.py b/lnbits/core/wasm_ext/api/permissions.py index 5c66228a9..a192e34b5 100644 --- a/lnbits/core/wasm_ext/api/permissions.py +++ b/lnbits/core/wasm_ext/api/permissions.py @@ -10,11 +10,14 @@ from lnbits.core.wasm_ext.wasm.config import ( ) _POLICY_AWARE_PERMISSION_IDS = { + "ext.storage.append_public", "ext.storage.read_public", "extension.api.request", "http.request", "wallet.create_invoice_public", } +_OWNER_ID_FIELD = "__lnbits_owner_id__" +_PUBLIC_APPEND_DEFAULT_MAX_ROWS_PER_SOURCE = 10_000 def validate_extension_permissions( @@ -127,6 +130,10 @@ def _permission_grant_is_subset( 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.append_public": + return _public_storage_append_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": @@ -229,6 +236,64 @@ def _public_storage_tables(policies: list[Any] | None) -> dict[str, set[str]]: 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( requested_policies: list[Any] | None, granted_policies: list[Any] | None, diff --git a/lnbits/core/wasm_ext/storage/__init__.py b/lnbits/core/wasm_ext/storage/__init__.py index 7362e2b83..e0a600bb7 100644 --- a/lnbits/core/wasm_ext/storage/__init__.py +++ b/lnbits/core/wasm_ext/storage/__init__.py @@ -1,5 +1,7 @@ from .crud import ( migrate_wasm_extension_database, + storage_append_public_row, + storage_count_rows, storage_delete_row, storage_get_paginated_rows, storage_get_public_row, @@ -10,6 +12,8 @@ from .crud import ( __all__ = [ "migrate_wasm_extension_database", + "storage_append_public_row", + "storage_count_rows", "storage_delete_row", "storage_get_paginated_rows", "storage_get_public_row", diff --git a/lnbits/core/wasm_ext/storage/crud.py b/lnbits/core/wasm_ext/storage/crud.py index f308a34eb..acd978787 100644 --- a/lnbits/core/wasm_ext/storage/crud.py +++ b/lnbits/core/wasm_ext/storage/crud.py @@ -5,6 +5,7 @@ import re from datetime import datetime, timezone from pathlib import Path from typing import Any +from uuid import uuid4 from loguru import logger @@ -110,6 +111,39 @@ async def storage_set_row( 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( ext_id: str, table: str, diff --git a/lnbits/static/i18n/en.js b/lnbits/static/i18n/en.js index e979e23d6..295b44b77 100644 --- a/lnbits/static/i18n/en.js +++ b/lnbits/static/i18n/en.js @@ -544,6 +544,10 @@ window.localisation.en = { extension_permission_warning_extension_api_request_write: 'Can write data or trigger actions in approved extensions.', 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_write: 'Write extension storage', extension_permission_ext_storage_read_write: 'Read & Write extension storage', diff --git a/lnbits/static/js/components/lnbits-extension-permissions.js b/lnbits/static/js/components/lnbits-extension-permissions.js index 8b865f68c..cdd37e4a9 100644 --- a/lnbits/static/js/components/lnbits-extension-permissions.js +++ b/lnbits/static/js/components/lnbits-extension-permissions.js @@ -115,6 +115,7 @@ 'wallet.list', 'wallet.balance.read', 'wallet.create_invoice_public', + 'ext.storage.append_public', 'ext.storage.read_public' ].includes(permission.id) ) { @@ -145,6 +146,7 @@ 'ext.storage.read', 'ext.storage.write', 'ext.storage.read_public', + 'ext.storage.append_public', 'wallet.create_invoice_public', 'wallet.create_invoice', 'utils.basic' @@ -194,6 +196,37 @@ .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) { const permission = permissions[0] const isReadWriteStorage = @@ -212,6 +245,7 @@ badges: [], descriptions, fieldGroups: [], + appendPolicies: [], invoicePolicies: [], extensionAccess: [], httpHosts: [] @@ -244,6 +278,15 @@ 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 } @@ -327,6 +370,9 @@ publicInvoicePolicySentence(policy) { 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) { const key = `extension_permission_access_${access}` const label = this.$t(key) diff --git a/lnbits/templates/components/lnbits-extension-permissions.vue b/lnbits/templates/components/lnbits-extension-permissions.vue index e81bebd6d..2515a5509 100644 --- a/lnbits/templates/components/lnbits-extension-permissions.vue +++ b/lnbits/templates/components/lnbits-extension-permissions.vue @@ -67,6 +67,35 @@ +
+
+ +
Path: {"name": "tags", "type": "string", "list": True}, {"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 = { @@ -398,7 +466,17 @@ def _write_storage_extension(settings: Settings, ext_id: str) -> Path: "op": "create_table", "table": "notes", "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")