From 2add71cf0df18f3b91b2ed44fdc0603a9c2637c2 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Jul 2026 13:03:42 +0300 Subject: [PATCH] refactor: components --- lnbits/core/extensions/routes.py | 176 ++++++-- lnbits/static/js/init-app.js | 10 + lnbits/static/js/wasm-extension-component.js | 400 +++++++++++++++++++ lnbits/static/vendor.json | 1 + lnbits/templates/base.html | 20 +- lnbits/templates/wasm_extension.html | 352 +--------------- package.json | 1 + 7 files changed, 580 insertions(+), 380 deletions(-) create mode 100644 lnbits/static/js/wasm-extension-component.js diff --git a/lnbits/core/extensions/routes.py b/lnbits/core/extensions/routes.py index d6fc0ca79..22748cad7 100644 --- a/lnbits/core/extensions/routes.py +++ b/lnbits/core/extensions/routes.py @@ -140,6 +140,8 @@ def _mount_wasm_extension_static(app: FastAPI, extension: WasmExtension) -> None def _register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -> None: + _add_wasm_extension_frame_config_route(app, extension) + for route_index, route_config in enumerate(extension.config.get("ui_routes") or []): route_path = _wasm_extension_ui_route_path(extension, route_config.get("path")) entrypoint = _wasm_extension_entrypoint( @@ -147,15 +149,12 @@ def _register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) - ) frame_path = f"/ext-frame/{extension.id}/{route_index}" auth = _wasm_extension_route_auth(extension, route_config.get("auth")) - path_params = route_config.get("path_params") or {} _add_wasm_extension_frame_route(app, extension, frame_path, entrypoint) _add_wasm_extension_wrapper_route( app, extension, route_path, - frame_path, auth, - path_params, ) @@ -222,6 +221,52 @@ def _add_wasm_extension_api_route( ) +def _add_wasm_extension_frame_config_route( + app: FastAPI, + extension: WasmExtension, +) -> None: + route_path = _wasm_extension_frame_config_path(extension) + if _has_route(app, route_path, "POST"): + return + + async def create_wasm_extension_frame_config( + request: Request, + access_token: Annotated[str | None, Depends(check_access_token)], + usr: UUID4 | None = None, + ) -> dict[str, Any]: + try: + body = await _read_json_object(request) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + ui_route = _match_wasm_extension_ui_route(extension, body.get("path")) + auth = ui_route["auth"] + + if auth == "user": + account = await check_account_exists(request, access_token, usr) + user_id: str | None = account.id + else: + user_id = await _optional_wasm_user_id(request, access_token, usr) + + return _wasm_extension_frame_config( + extension, + ui_route["frame_path"], + auth, + ui_route["path_params"], + ui_route["route_params"], + _read_wasm_extension_route_query(body.get("query")), + user_id, + ) + + app.add_api_route( + route_path, + create_wasm_extension_frame_config, + methods=["POST"], + name=f"{extension.id}:frame-config", + include_in_schema=False, + ) + + async def _read_api_payload( request: Request, path_params: dict[str, str], @@ -307,9 +352,7 @@ def _add_wasm_extension_wrapper_route( app: FastAPI, extension: WasmExtension, route_path: str, - frame_path: str, auth: str, - path_params: dict[str, str], ) -> None: if _has_route(app, route_path, "GET"): return @@ -322,25 +365,16 @@ def _add_wasm_extension_wrapper_route( return _wasm_extension_wrapper_response( request, extension, - frame_path, auth, - path_params, user.json() if user else None, - account.id, ) - async def serve_public_wasm_extension_page( - request: Request, - user_id: str | None = Depends(_optional_wasm_user_id), - ) -> Any: + async def serve_public_wasm_extension_page(request: Request) -> Any: return _wasm_extension_wrapper_response( request, extension, - frame_path, auth, - path_params, None, - user_id, ) app.add_api_route( @@ -398,11 +432,8 @@ def _add_wasm_extension_frame_route( def _wasm_extension_wrapper_response( request: Request, extension: WasmExtension, - frame_path: str, auth: str, - path_params: dict[str, str], user_json: str | None, - user_id: str | None, ) -> Any: public = auth == "public" response = template_renderer().TemplateResponse( @@ -410,14 +441,6 @@ def _wasm_extension_wrapper_response( "wasm_extension.html", { "extension": extension, - "frame_url": _wasm_extension_frame_url(extension, frame_path, user_id), - "bridge": { - "extensionId": extension.id, - "public": public, - "routeParams": _read_api_path_params(request, path_params), - "query": _read_api_query_params(request), - "apiRoutes": _wasm_extension_bridge_api_routes(extension, public), - }, "public": public, "user": user_json, }, @@ -541,6 +564,107 @@ def _wasm_extension_bridge_api_routes( return routes +def _wasm_extension_frame_config_path(extension: WasmExtension) -> str: + return f"/api/v1/ext/{extension.id}/_ui/frame" + + +def _match_wasm_extension_ui_route( + extension: WasmExtension, + path: Any, +) -> dict[str, Any]: + if not isinstance(path, str) or not path.startswith("/"): + raise HTTPException(status_code=404, detail="Not found") + + for route_index, route_config in enumerate(extension.config.get("ui_routes") or []): + route_path = _wasm_extension_ui_route_path(extension, route_config.get("path")) + route_params = _path_template_params(route_path, path) + if route_params is None: + continue + + return { + "frame_path": f"/ext-frame/{extension.id}/{route_index}", + "auth": _wasm_extension_route_auth(extension, route_config.get("auth")), + "path_params": route_config.get("path_params") or {}, + "route_params": route_params, + } + + raise HTTPException(status_code=404, detail="Not found") + + +def _path_template_params(template: str, path: str) -> dict[str, str] | None: + template_parts = _path_parts(template) + path_parts = _path_parts(path) + if len(template_parts) != len(path_parts): + return None + + params: dict[str, str] = {} + for template_part, path_part in zip(template_parts, path_parts, strict=False): + if template_part.startswith("{") and template_part.endswith("}"): + param_name = template_part[1:-1] + if not param_name: + return None + params[param_name] = path_part + continue + + if template_part != path_part: + return None + + return params + + +def _path_parts(path: str) -> list[str]: + return [part for part in path.strip("/").split("/") if part] + + +def _wasm_extension_frame_config( + extension: WasmExtension, + frame_path: str, + auth: str, + path_params: dict[str, str], + route_params: dict[str, str], + query: dict[str, Any], + user_id: str | None, +) -> dict[str, Any]: + public = auth == "public" + return { + "extension": { + "id": extension.id, + "name": extension.name, + }, + "frameUrl": _wasm_extension_frame_url(extension, frame_path, user_id), + "bridge": { + "extensionId": extension.id, + "public": public, + "routeParams": _map_wasm_extension_route_params(route_params, path_params), + "query": query, + "apiRoutes": _wasm_extension_bridge_api_routes(extension, public), + }, + } + + +def _map_wasm_extension_route_params( + route_params: dict[str, str], + path_params: dict[str, str], +) -> dict[str, str]: + payload: dict[str, str] = {} + for key, value in route_params.items(): + target = path_params.get(key) or _snake_to_camel(key) + payload[target] = value + return payload + + +def _read_wasm_extension_route_query(query: Any) -> dict[str, Any]: + if not isinstance(query, dict): + return {} + + payload: dict[str, Any] = {} + for key, value in query.items(): + if value is None: + continue + payload[_snake_to_camel(str(key))] = value + return payload + + def _path_template_pattern(path: str) -> str: pattern = re.sub(r"\\{[^/{}]+\\}", r"[^/]+", re.escape(path)) return f"^{pattern}$" diff --git a/lnbits/static/js/init-app.js b/lnbits/static/js/init-app.js index 1f0dfe327..2c0d9c95e 100644 --- a/lnbits/static/js/init-app.js +++ b/lnbits/static/js/init-app.js @@ -139,6 +139,16 @@ const routes = [ name: 'PageError', component: PageError }, + { + path: '/ext/:extId', + name: 'WasmExtensionRoot', + component: window.WasmExtensionComponent + }, + { + path: '/ext/:extId/:pathMatch(.*)*', + name: 'WasmExtension', + component: window.WasmExtensionComponent + }, { path: '/:pathMatch(.*)*', name: 'DynamicComponent', diff --git a/lnbits/static/js/wasm-extension-component.js b/lnbits/static/js/wasm-extension-component.js new file mode 100644 index 000000000..94df0d9ea --- /dev/null +++ b/lnbits/static/js/wasm-extension-component.js @@ -0,0 +1,400 @@ +window.WasmExtensionComponent = { + template: ` +
+ + + + + {{ error }} + + +
+ `, + data() { + return { + allowedPaymentHashes: new Set(), + bridge: { + apiRoutes: [], + extensionId: '', + public: false, + query: {}, + routeParams: {} + }, + bridgePort: null, + error: '', + extensionName: '', + frameUrl: '', + handleWindowMessage: null, + loading: false, + loadId: 0, + paymentSubscriptions: new Map() + } + }, + created() { + this.handleWindowMessage = event => this.onWindowMessage(event) + window.addEventListener('message', this.handleWindowMessage) + }, + unmounted() { + window.removeEventListener('message', this.handleWindowMessage) + this.closeBridgePort() + }, + watch: { + '$route.fullPath': { + immediate: true, + handler() { + this.loadFrameConfig() + } + } + }, + methods: { + emptyBridge() { + return { + apiRoutes: [], + extensionId: '', + public: false, + query: {}, + routeParams: {} + } + }, + plainBridgeContext() { + return { + extensionId: String(this.bridge.extensionId || ''), + public: Boolean(this.bridge.public), + routeParams: this.plainValue(this.bridge.routeParams || {}), + query: this.plainValue(this.bridge.query || {}) + } + }, + plainValue(value) { + try { + return JSON.parse(JSON.stringify(value)) + } catch (_error) { + return {} + } + }, + async loadFrameConfig() { + const extId = String(this.$route.params.extId || '') + const loadId = ++this.loadId + this.loading = true + this.error = '' + this.frameUrl = '' + this.bridge = this.emptyBridge() + this.allowedPaymentHashes.clear() + this.closeBridgePort() + + try { + const response = await fetch( + `/api/v1/ext/${encodeURIComponent(extId)}/_ui/frame`, + { + method: 'POST', + headers: {'content-type': 'application/json'}, + credentials: 'same-origin', + body: JSON.stringify({ + path: this.$route.path, + query: this.$route.query || {} + }) + } + ) + const text = await response.text() + let data = {} + if (text) { + try { + data = JSON.parse(text) + } catch (_error) { + data = {detail: text} + } + } + if (!response.ok) { + throw new Error(data?.detail || 'Failed to load extension page.') + } + if (loadId !== this.loadId) return + + this.bridge = data.bridge || this.emptyBridge() + this.extensionName = data.extension?.name || extId + this.frameUrl = data.frameUrl + } catch (error) { + if (loadId !== this.loadId) return + console.error('[lnbits wasm extension] Failed to load frame.', error) + this.error = error instanceof Error ? error.message : String(error) + } finally { + if (loadId === this.loadId) { + this.loading = false + } + } + }, + extensionFrameWindow() { + return this.$refs.frame?.contentWindow + }, + sendResponse(reply, id, payload) { + reply({ + type: 'lnbits-extension:response', + id, + ...payload + }) + }, + allowedApiRoute(method, path) { + let url + try { + url = new URL(path, window.location.origin) + } catch (_error) { + return false + } + if (url.origin !== window.location.origin) return false + + method = String(method || 'GET').toUpperCase() + return (this.bridge.apiRoutes || []).some(route => { + return ( + route.method === method && + new RegExp(route.pattern).test(url.pathname) + ) + }) + }, + async callApi(message) { + const method = String(message.method || 'GET').toUpperCase() + const path = String(message.path || '') + if (!this.allowedApiRoute(method, path)) { + throw new Error('Extension API route is not allowed.') + } + + const options = { + method, + headers: {}, + credentials: 'same-origin' + } + if (message.body !== undefined && message.body !== null) { + options.headers['content-type'] = 'application/json' + options.body = JSON.stringify(message.body) + } + + const response = await fetch(path, options) + const text = await response.text() + let data = text + if (text) { + try { + data = JSON.parse(text) + } catch (_error) { + data = text + } + } + if (!response.ok) { + throw new Error( + typeof data === 'object' && data.detail ? data.detail : text + ) + } + this.rememberPaymentHashes(data) + return data + }, + notify(message) { + const level = ['positive', 'negative', 'warning', 'info'].includes( + message.level + ) + ? message.level + : 'info' + if (window.Quasar?.Notify) { + window.Quasar.Notify.create({ + color: level, + message: String(message.message || '') + }) + } + }, + rememberPaymentHashes(value) { + if (!value || typeof value !== 'object') return + + if (Array.isArray(value)) { + value.forEach(item => this.rememberPaymentHashes(item)) + return + } + + for (const [key, item] of Object.entries(value)) { + if ( + ['paymentHash', 'payment_hash'].includes(key) && + this.isPaymentHash(item) + ) { + this.allowedPaymentHashes.add(item) + } + this.rememberPaymentHashes(item) + } + }, + isPaymentHash(value) { + return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value) + }, + websocketUrl(path) { + const url = new URL(window.location.href) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + url.pathname = path + url.search = '' + url.hash = '' + return url.toString() + }, + sendBridgeEvent(message) { + if (!this.bridgePort) return + this.bridgePort.postMessage({ + type: 'lnbits-extension:event', + ...message + }) + }, + closePaymentSubscription(subscriptionId) { + const subscription = this.paymentSubscriptions.get(subscriptionId) + if (!subscription) return + this.paymentSubscriptions.delete(subscriptionId) + try { + subscription.socket.close() + } catch (_error) {} + }, + closePaymentSubscriptions() { + for (const subscriptionId of Array.from( + this.paymentSubscriptions.keys() + )) { + this.closePaymentSubscription(subscriptionId) + } + }, + closeBridgePort() { + this.closePaymentSubscriptions() + this.bridgePort?.close() + this.bridgePort = null + }, + subscribePayment(message) { + const subscriptionId = String(message.subscriptionId || '') + const paymentHash = String(message.paymentHash || '') + + if (!subscriptionId || !this.isPaymentHash(paymentHash)) { + throw new Error('Invalid payment subscription.') + } + if (!this.allowedPaymentHashes.has(paymentHash)) { + throw new Error('Payment subscription is not allowed.') + } + + this.closePaymentSubscription(subscriptionId) + + const socket = new WebSocket( + this.websocketUrl(`/api/v1/ws/${encodeURIComponent(paymentHash)}`) + ) + this.paymentSubscriptions.set(subscriptionId, {paymentHash, socket}) + + socket.addEventListener('message', event => { + let data = event.data + try { + data = JSON.parse(event.data) + } catch (_error) {} + + this.sendBridgeEvent({ + event: 'payment.update', + subscriptionId, + paymentHash, + data + }) + + if ( + data && + typeof data === 'object' && + (data.pending === false || + ['success', 'settled', 'paid'].includes(String(data.status || ''))) + ) { + this.sendBridgeEvent({ + event: 'payment.settled', + subscriptionId, + paymentHash, + data + }) + this.closePaymentSubscription(subscriptionId) + } + }) + socket.addEventListener('error', () => { + this.sendBridgeEvent({ + event: 'payment.error', + subscriptionId, + paymentHash + }) + this.closePaymentSubscription(subscriptionId) + }) + socket.addEventListener('close', () => { + this.paymentSubscriptions.delete(subscriptionId) + }) + }, + async handleBridgeRequest(message, reply) { + if (!message || message.type !== 'lnbits-extension:request') return + + try { + if (message.action === 'context') { + this.sendResponse(reply, message.id, { + ok: true, + data: this.plainBridgeContext() + }) + return + } + + if (message.action === 'api') { + this.sendResponse(reply, message.id, { + ok: true, + data: await this.callApi(message) + }) + return + } + + if (message.action === 'ui.notify') { + this.notify(message) + this.sendResponse(reply, message.id, { + ok: true, + data: {ok: true} + }) + return + } + + if (message.action === 'payment.subscribe') { + this.subscribePayment(message) + this.sendResponse(reply, message.id, { + ok: true, + data: {ok: true} + }) + return + } + + if (message.action === 'payment.unsubscribe') { + this.closePaymentSubscription(String(message.subscriptionId || '')) + this.sendResponse(reply, message.id, { + ok: true, + data: {ok: true} + }) + return + } + + throw new Error('Unknown extension bridge action.') + } catch (error) { + this.sendResponse(reply, message.id, { + ok: false, + error: error instanceof Error ? error.message : String(error) + }) + } + }, + onWindowMessage(event) { + if (event.source !== this.extensionFrameWindow()) return + const message = event.data + if (!message || message.type !== 'lnbits-extension:connect') return + + const port = event.ports?.[0] + if (!port) return + + this.closeBridgePort() + this.bridgePort = port + this.bridgePort.addEventListener('message', portEvent => { + this.handleBridgeRequest(portEvent.data, response => { + port.postMessage(response) + }) + }) + this.bridgePort.start() + this.bridgePort.postMessage({ + type: 'lnbits-extension:connected', + id: message.id + }) + } + } +} diff --git a/lnbits/static/vendor.json b/lnbits/static/vendor.json index 3f7849a48..0d540800d 100644 --- a/lnbits/static/vendor.json +++ b/lnbits/static/vendor.json @@ -97,6 +97,7 @@ "js/components/extension-settings.js", "js/components/data-fields.js", "js/components.js", + "js/wasm-extension-component.js", "js/init-app.js" ], "css": ["vendor/quasar.css", "css/base.css"] diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html index c81271b2e..0fdff8456 100644 --- a/lnbits/templates/base.html +++ b/lnbits/templates/base.html @@ -16,6 +16,18 @@ src: url("{{ static_url_for('static', 'fonts/material-icons-v50.woff2') }}") format('woff2'); } + + .wasm-extension-page, + .wasm-extension-frame { + height: calc(100vh - 56px); + min-height: calc(100vh - 56px); + width: 100%; + } + + .wasm-extension-frame { + border: 0; + display: block; + } {% block title %}{{ SITE_TITLE }}{% endblock %} @@ -44,12 +56,14 @@ {% block page_container %} - +
- .wasm-extension-frame { - border: 0; - display: block; - height: calc(100vh - 56px); - min-height: calc(100vh - 56px); - width: 100%; - } - -{% endblock %} {% block page_container %} - - - - - - - - - - - -{% endblock %} {% block scripts %} - -{% endblock %} +{% extends "base.html" %} diff --git a/package.json b/package.json index 10c31d6e0..0ecc68129 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,7 @@ "js/components/extension-settings.js", "js/components/data-fields.js", "js/components.js", + "js/wasm-extension-component.js", "js/init-app.js" ], "css": [