diff --git a/lnbits/core/extensions/api.py b/lnbits/core/extensions/api.py index 6a0dae90b..e07674947 100644 --- a/lnbits/core/extensions/api.py +++ b/lnbits/core/extensions/api.py @@ -21,6 +21,7 @@ from .models import ( KvListResponse, KvSetRequest, KvSetResponse, + ListUserWalletsResponse, LogRequest, LogResponse, NowResponse, @@ -173,6 +174,20 @@ class ExtensionAPI: ) -> CreateInvoiceResponse: self._raise_unwired_runtime("wallet_create_invoice") + @extension_api_method( + method_id="wallet.list_user_wallets", + namespace="wallet", + name="List user wallets", + host_name="list_user_wallets", + sdk_name="listUserWallets", + description="List wallets available to the authenticated extension user.", + required_permission="wallet.list", + ) + async def wallet_list_user_wallets( + self, request: EmptyRequest + ) -> ListUserWalletsResponse: + self._raise_unwired_runtime("wallet_list_user_wallets") + @extension_api_method( method_id="payments.watch", namespace="payments", diff --git a/lnbits/core/extensions/codegen/typescript.py b/lnbits/core/extensions/codegen/typescript.py index 5ca50934e..84256bfdb 100644 --- a/lnbits/core/extensions/codegen/typescript.py +++ b/lnbits/core/extensions/codegen/typescript.py @@ -69,12 +69,31 @@ def _select_methods( def _collect_models(methods: Sequence[ExtensionAPIMethod]) -> list[type[BaseModel]]: models: dict[str, type[BaseModel]] = {} - for method in methods: - models[method.request_model.__name__] = method.request_model - models[method.response_model.__name__] = method.response_model + pending = [ + model + for method in methods + for model in (method.request_model, method.response_model) + ] + + while pending: + model = pending.pop() + if model.__name__ in models: + continue + models[model.__name__] = model + for field in model.__fields__.values(): + pending.extend(_nested_model_types(field.outer_type_)) return [models[name] for name in sorted(models)] +def _nested_model_types(type_: Any) -> list[type[BaseModel]]: + models: list[type[BaseModel]] = [] + if _is_model_type(type_): + models.append(type_) + for arg in get_args(type_): + models.extend(_nested_model_types(arg)) + return models + + def _render_model_type(model: type[BaseModel]) -> list[str]: name = _model_name(model) fields = model.__fields__ @@ -105,7 +124,9 @@ def _python_type_to_ts(type_: Any, allow_none: bool = False) -> str: if origin is Literal: return " | ".join(_literal_to_ts(arg) for arg in args) - if origin in (list, Sequence): + if _is_model_type(type_): + ts = _model_name(type_) + elif origin in (list, Sequence): item_type = _python_type_to_ts(args[0]) if args else "unknown" ts = f"{item_type}[]" elif origin is dict: @@ -145,6 +166,10 @@ def _is_subclass(type_: Any, class_: type) -> bool: return False +def _is_model_type(type_: Any) -> bool: + return _is_subclass(type_, BaseModel) + + def _render_method_metadata( methods: Sequence[ExtensionAPIMethod], ) -> list[str]: diff --git a/lnbits/core/extensions/loader.py b/lnbits/core/extensions/loader.py index d85a31f5b..e5e8e4dd6 100644 --- a/lnbits/core/extensions/loader.py +++ b/lnbits/core/extensions/loader.py @@ -192,16 +192,21 @@ def _add_wasm_extension_api_route( if _has_route(app, route_path, method): return - async def invoke_wasm_extension_export(request: Request) -> dict[str, Any]: - from .wasm import invoke_wasm_extension_export as invoke_export + require_user = _require_wasm_user_extension(extension.id) + + async def invoke_wasm_api_request( + request: Request, user: Any | None = None + ) -> dict[str, Any]: + from .wasm import invoke_wasm_extension_export as invoke_wasm_export try: payload = await _read_api_payload(request, path_params) - return await invoke_export( + return await invoke_wasm_export( app, extension.id, export_name, payload, + user=user, ) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @@ -210,12 +215,24 @@ def _add_wasm_extension_api_route( except (TypeError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + async def invoke_private_wasm_extension_export( + request: Request, + user: Any = Depends(require_user), + ) -> dict[str, Any]: + return await invoke_wasm_api_request(request, user) + + async def invoke_public_wasm_extension_export(request: Request) -> dict[str, Any]: + return await invoke_wasm_api_request(request) + app.add_api_route( route_path, - invoke_wasm_extension_export, + ( + invoke_public_wasm_extension_export + if auth == "public" + else invoke_private_wasm_extension_export + ), methods=[method], name=f"{extension.id}:{method}:{route_path}", - dependencies=_wasm_extension_dependencies(extension, auth), include_in_schema=False, ) @@ -362,14 +379,8 @@ def _add_wasm_extension_frame_route( async def serve_wasm_extension_frame(request: Request) -> FileResponse: _consume_wasm_extension_frame_token(request, extension, frame_path) response = FileResponse(entrypoint) - response.headers["Content-Security-Policy"] = ( - "sandbox allow-scripts allow-forms; " - "default-src 'self' data: blob:; " - "connect-src 'none'; " - "form-action 'none'; " - "object-src 'none'; " - "base-uri 'none'; " - "frame-ancestors 'self'" + response.headers["Content-Security-Policy"] = _wasm_extension_frame_csp( + request, extension ) response.headers["Cache-Control"] = "no-store" response.headers["Cross-Origin-Opener-Policy"] = "same-origin" @@ -423,6 +434,31 @@ def _wasm_extension_wrapper_response( return response +def _wasm_extension_frame_csp(request: Request, extension: WasmExtension) -> str: + origin = str(request.base_url).rstrip("/") + extension_assets = f"{origin}/ext-assets/{extension.id}/" + static_assets = f"{origin}/static/" + return ( + "sandbox allow-scripts; " + "default-src 'none'; " + f"script-src {extension_assets}; " + "script-src-attr 'none'; " + f"style-src {extension_assets} {static_assets}; " + "style-src-attr 'none'; " + f"img-src {extension_assets} {static_assets}; " + f"font-src {static_assets}; " + "connect-src 'none'; " + "form-action 'none'; " + "object-src 'none'; " + "base-uri 'none'; " + "frame-src 'none'; " + "worker-src 'none'; " + "media-src 'none'; " + "manifest-src 'none'; " + "frame-ancestors 'self'" + ) + + def _wasm_extension_frame_url(extension: WasmExtension, frame_path: str) -> str: token = _create_wasm_extension_frame_token(extension, frame_path) return f"{frame_path}?frame_token={token}" @@ -506,15 +542,6 @@ def _path_template_pattern(path: str) -> str: return f"^{pattern}$" -def _wasm_extension_dependencies( - extension: WasmExtension, - auth: str, -) -> list[Any]: - if auth == "public": - return [] - return [Depends(_require_wasm_user_extension(extension.id))] - - def _require_wasm_user_extension(ext_id: str) -> Any: async def require_wasm_user_extension( user: Any = Depends(check_user_exists), diff --git a/lnbits/core/extensions/models.py b/lnbits/core/extensions/models.py index b22dfe441..ba6388114 100644 --- a/lnbits/core/extensions/models.py +++ b/lnbits/core/extensions/models.py @@ -46,6 +46,16 @@ class CreateInvoiceResponse(BaseModel): checking_id: str +class UserWalletSummary(BaseModel): + id: str + name: str + currency: str | None = None + + +class ListUserWalletsResponse(BaseModel): + wallets: list[UserWalletSummary] = Field(default_factory=list) + + class WatchPaymentRequest(BaseModel): payment_hash: str = Field(..., min_length=1, max_length=128) callback_export: str = Field(..., min_length=1, max_length=128) diff --git a/lnbits/core/extensions/prototype.py b/lnbits/core/extensions/prototype.py index 74b768c95..268af6a28 100644 --- a/lnbits/core/extensions/prototype.py +++ b/lnbits/core/extensions/prototype.py @@ -9,12 +9,15 @@ from .api import ExtensionAPI from .models import ( CreateInvoiceRequest, CreateInvoiceResponse, + EmptyRequest, KvGetRequest, KvGetResponse, KvListRequest, KvListResponse, KvSetRequest, KvSetResponse, + ListUserWalletsResponse, + UserWalletSummary, WatchPaymentRequest, WatchPaymentResponse, ) @@ -35,6 +38,7 @@ class InMemoryExtensionAPI(ExtensionAPI): state: InMemoryExtensionState | None = None, user_id: str | None = None, wallet_id: str | None = None, + user_wallets: list[UserWalletSummary] | None = None, ) -> None: super().__init__( extension_id, @@ -43,6 +47,7 @@ class InMemoryExtensionAPI(ExtensionAPI): wallet_id=wallet_id, ) self.state = state or InMemoryExtensionState() + self.user_wallets = list(user_wallets) if user_wallets is not None else None async def storage_get(self, request: KvGetRequest) -> KvGetResponse: self.require_permission("ext.storage.read_write") @@ -77,6 +82,16 @@ class InMemoryExtensionAPI(ExtensionAPI): checking_id=f"{self.extension_id}:{payment_hash}", ) + async def wallet_list_user_wallets( + self, _request: EmptyRequest + ) -> ListUserWalletsResponse: + self.require_permission("wallet.list") + if self.user_wallets is None: + raise PermissionError( + "Listing user wallets requires an authenticated user context." + ) + return ListUserWalletsResponse(wallets=self.user_wallets) + async def payments_watch( self, request: WatchPaymentRequest ) -> WatchPaymentResponse: diff --git a/lnbits/core/extensions/wasm.py b/lnbits/core/extensions/wasm.py index d530fb11f..c23100f18 100644 --- a/lnbits/core/extensions/wasm.py +++ b/lnbits/core/extensions/wasm.py @@ -11,6 +11,7 @@ from fastapi import FastAPI from .api import list_extension_api_methods from .loader import WasmExtension, register_wasm_extension +from .models import UserWalletSummary from .prototype import InMemoryExtensionAPI, InMemoryExtensionState from .runtime import ExtensionAPIHost @@ -20,11 +21,19 @@ async def invoke_wasm_extension_export( ext_id: str, export_name: str, payload: Mapping[str, Any] | None = None, + *, + user: Any | None = None, ) -> dict[str, Any]: extension = _get_registered_extension(app, ext_id) state = _get_extension_state(app) permissions = _extension_permissions(extension) - api = InMemoryExtensionAPI(extension.id, permissions, state=state) + api = InMemoryExtensionAPI( + extension.id, + permissions, + state=state, + user_id=_user_id(user), + user_wallets=_user_wallet_summaries(user), + ) return await asyncio.to_thread( _invoke_wasm_extension_export_sync, @@ -143,10 +152,18 @@ def _dict_to_component_record(value: Mapping[str, Any]) -> Any: record = component.Record() for key, item in value.items(): - setattr(record, _camel_to_kebab(key), item) + setattr(record, _camel_to_kebab(key), _to_component_value(item)) return record +def _to_component_value(value: Any) -> Any: + if isinstance(value, Mapping): + return _dict_to_component_record(value) + if isinstance(value, list): + return [_to_component_value(item) for item in value] + return value + + def _parse_wasm_export_result(extension: WasmExtension, value: Any) -> dict[str, Any]: if isinstance(value, bytes): value = value.decode() @@ -195,5 +212,31 @@ def _extension_permissions(extension: WasmExtension) -> set[str]: return permissions +def _user_id(user: Any | None) -> str | None: + return getattr(user, "id", None) if user else None + + +def _user_wallet_summaries(user: Any | None) -> list[UserWalletSummary] | None: + if user is None: + return None + + summaries: list[UserWalletSummary] = [] + for wallet in getattr(user, "wallets", []) or []: + if not getattr(wallet, "can_receive_payments", False): + continue + wallet_id = getattr(wallet, "id", None) + wallet_name = getattr(wallet, "name", None) + if not wallet_id or not wallet_name: + continue + summaries.append( + UserWalletSummary( + id=wallet_id, + name=wallet_name, + currency=getattr(wallet, "currency", None), + ) + ) + return summaries + + def _camel_to_kebab(value: str) -> str: return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value).replace("_", "-").lower() diff --git a/lnbits/templates/wasm_extension.html b/lnbits/templates/wasm_extension.html index 95633b0fc..7ccd20c58 100644 --- a/lnbits/templates/wasm_extension.html +++ b/lnbits/templates/wasm_extension.html @@ -16,7 +16,7 @@ class="wasm-extension-frame" data-frame-url="{{ frame_url }}" title="{{ extension.name }}" - sandbox="allow-scripts allow-forms" + sandbox="allow-scripts" allow="clipboard-write" referrerpolicy="no-referrer" >