feat: serve in iframe

This commit is contained in:
Vlad Stan
2026-07-09 16:30:18 +03:00
parent 4a38d4219c
commit 41db2b2819
2 changed files with 348 additions and 17 deletions
+189 -17
View File
@@ -1,15 +1,19 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
from fastapi import FastAPI, HTTPException, Request
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from loguru import logger
from lnbits.decorators import check_user_exists, check_user_extension_access
from lnbits.helpers import template_renderer
from lnbits.settings import settings
@@ -48,7 +52,7 @@ def register_wasm_extension(app: FastAPI, ext_id: str) -> WasmExtension:
warm_wasm_extension(loaded)
_mount_wasm_extension_static(app, loaded)
_register_wasm_extension_routes(app, loaded)
_register_wasm_extension_ui_routes(app, loaded)
_register_wasm_extension_api_routes(app, loaded)
extensions = getattr(app.state, "lnbits_wasm_extensions", {})
@@ -97,7 +101,7 @@ def _mount_wasm_extension_static(app: FastAPI, extension: WasmExtension) -> None
if not static_path.is_dir():
return
mount_path = f"/{extension.id}/static"
mount_path = f"/ext-assets/{extension.id}"
if any(getattr(route, "path", None) == mount_path for route in app.routes):
return
@@ -108,13 +112,24 @@ def _mount_wasm_extension_static(app: FastAPI, extension: WasmExtension) -> None
)
def _register_wasm_extension_routes(app: FastAPI, extension: WasmExtension) -> None:
for route_config in extension.config.get("routes") or []:
route_path = _wasm_extension_route_path(extension, route_config.get("path"))
def _register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -> None:
for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
route_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
entrypoint = _wasm_extension_entrypoint(
extension, route_config.get("entrypoint")
)
_add_wasm_extension_page_route(app, extension, route_path, entrypoint)
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, auth)
_add_wasm_extension_wrapper_route(
app,
extension,
route_path,
frame_path,
auth,
path_params,
)
def _register_wasm_extension_api_routes(app: FastAPI, extension: WasmExtension) -> None:
@@ -131,6 +146,7 @@ def _add_wasm_extension_api_route(
route_path = _wasm_extension_api_path(extension, route_config.get("path"))
export_name = _wasm_extension_api_export(extension, route_config.get("export"))
path_params = route_config.get("path_params") or {}
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
if _has_route(app, route_path, method):
return
@@ -158,6 +174,7 @@ def _add_wasm_extension_api_route(
invoke_wasm_extension_export,
methods=[method],
name=f"{extension.id}:{method}:{route_path}",
dependencies=_wasm_extension_dependencies(extension, auth),
include_in_schema=False,
)
@@ -243,27 +260,176 @@ def _snake_to_camel(value: str) -> str:
return head + "".join(part.capitalize() for part in tail)
def _add_wasm_extension_page_route(
def _add_wasm_extension_wrapper_route(
app: FastAPI,
extension: WasmExtension,
route_path: str,
entrypoint: Path,
frame_path: str,
auth: str,
path_params: dict[str, str],
) -> None:
if any(getattr(route, "path", None) == route_path for route in app.routes):
if _has_route(app, route_path, "GET"):
return
async def serve_wasm_extension_page() -> FileResponse:
return FileResponse(entrypoint)
require_user = _require_wasm_user_extension(extension.id)
async def serve_private_wasm_extension_page(
request: Request,
user: Any = Depends(require_user),
) -> Any:
return _wasm_extension_wrapper_response(
request,
extension,
frame_path,
auth,
path_params,
user.json(),
)
async def serve_public_wasm_extension_page(request: Request) -> Any:
return _wasm_extension_wrapper_response(
request,
extension,
frame_path,
auth,
path_params,
None,
)
app.add_api_route(
route_path,
serve_wasm_extension_page,
(
serve_public_wasm_extension_page
if auth == "public"
else serve_private_wasm_extension_page
),
methods=["GET"],
name=f"{extension.id}:{route_path}",
include_in_schema=False,
)
def _add_wasm_extension_frame_route(
app: FastAPI,
extension: WasmExtension,
frame_path: str,
entrypoint: Path,
auth: str,
) -> None:
if _has_route(app, frame_path, "GET"):
return
async def serve_wasm_extension_frame() -> FileResponse:
response = FileResponse(entrypoint)
response.headers["Content-Security-Policy"] = (
"sandbox allow-scripts allow-forms; "
"default-src 'self' data: blob:; "
"connect-src 'none'; "
"object-src 'none'; "
"base-uri 'none'; "
"frame-ancestors 'self'"
)
response.headers["X-Content-Type-Options"] = "nosniff"
return response
app.add_api_route(
frame_path,
serve_wasm_extension_frame,
methods=["GET"],
name=f"{extension.id}:frame:{frame_path}",
dependencies=_wasm_extension_dependencies(extension, auth),
include_in_schema=False,
)
def _wasm_extension_wrapper_response(
request: Request,
extension: WasmExtension,
frame_path: str,
auth: str,
path_params: dict[str, str],
user_json: str | None,
) -> Any:
frame_url = _wasm_extension_frame_url(request, frame_path, path_params)
public = auth == "public"
return template_renderer().TemplateResponse(
request,
"wasm_extension.html",
{
"extension": extension,
"frame_url": frame_url,
"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,
},
)
def _wasm_extension_frame_url(
request: Request,
frame_path: str,
path_params: dict[str, str],
) -> str:
params = _read_api_path_params(request, path_params)
params.update(_read_api_query_params(request))
query = urlencode(params)
return f"{frame_path}?{query}" if query else frame_path
def _wasm_extension_bridge_api_routes(
extension: WasmExtension,
public: bool,
) -> list[dict[str, str]]:
routes: list[dict[str, str]] = []
for route_config in extension.config.get("api_routes") or []:
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
if public and auth != "public":
continue
method = _wasm_extension_api_method(extension, route_config.get("method"))
path = _wasm_extension_api_path(extension, route_config.get("path"))
_wasm_extension_api_export(extension, route_config.get("export"))
routes.append(
{
"method": method,
"path": path,
"pattern": _path_template_pattern(path),
}
)
return routes
def _path_template_pattern(path: str) -> str:
pattern = re.sub(r"\\{[^/{}]+\\}", r"[^/]+", re.escape(path))
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),
) -> Any:
status = await check_user_extension_access(user.id, ext_id)
if not status.success:
raise HTTPException(status_code=403, detail=status.message)
return user
return require_wasm_user_extension
def _load_json(path: Path) -> dict[str, Any] | None:
if not path.is_file():
return None
@@ -289,12 +455,18 @@ def _optional_extension_path(ext_dir: Path, value: Any) -> Path | None:
return _extension_path(ext_dir, value)
def _wasm_extension_route_path(extension: WasmExtension, path: Any) -> str:
def _wasm_extension_route_auth(extension: WasmExtension, auth: Any) -> str:
if auth in {"public", "user"}:
return auth
raise ValueError(f"Invalid route auth for WASM extension '{extension.id}'.")
def _wasm_extension_ui_route_path(extension: WasmExtension, path: Any) -> str:
if not isinstance(path, str) or not path.startswith("/"):
raise ValueError(f"Invalid route path for WASM extension '{extension.id}'.")
if path == "/":
return f"/{extension.id}/"
return f"/{extension.id}{path}"
return "/ext"
return f"/ext{path}"
def _wasm_extension_entrypoint(extension: WasmExtension, entrypoint: Any) -> Path:
@@ -303,7 +475,7 @@ def _wasm_extension_entrypoint(extension: WasmExtension, entrypoint: Any) -> Pat
f"Invalid route entrypoint for WASM extension '{extension.id}'."
)
static_prefix = f"/{extension.id}/static/"
static_prefix = f"/ext-assets/{extension.id}/"
if not entrypoint.startswith(static_prefix):
raise ValueError(
f"Route entrypoint for WASM extension '{extension.id}' must be under "
+159
View File
@@ -0,0 +1,159 @@
{% extends "base.html" %}
{% block styles %}
<style>
.wasm-extension-frame {
border: 0;
display: block;
min-height: calc(100vh - 170px);
width: 100%;
}
.wasm-extension-public {
margin: 0 auto;
max-width: 1180px;
}
</style>
{% endblock %}
{% block page %}
<div class="{% if public %}wasm-extension-public{% endif %}">
<iframe
id="lnbits-wasm-extension-frame"
class="wasm-extension-frame"
src="{{ frame_url }}"
title="{{ extension.name }}"
sandbox="allow-scripts allow-forms"
referrerpolicy="no-referrer"
></iframe>
</div>
{% endblock %}
{% block scripts %}
<script>
;(() => {
const bridge = {{ bridge | tojson | safe }}
const frame = document.getElementById('lnbits-wasm-extension-frame')
function sendResponse(id, payload) {
frame.contentWindow?.postMessage(
{
type: 'lnbits-extension:response',
id,
...payload
},
'*'
)
}
function 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 bridge.apiRoutes.some(route => {
return (
route.method === method &&
new RegExp(route.pattern).test(url.pathname)
)
})
}
async function callApi(message) {
const method = String(message.method || 'GET').toUpperCase()
const path = String(message.path || '')
if (!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
)
}
return data
}
function 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 || '')
})
}
}
window.addEventListener('message', async event => {
if (event.source !== frame.contentWindow) return
const message = event.data
if (!message || message.type !== 'lnbits-extension:request') return
try {
if (message.action === 'context') {
sendResponse(message.id, {
ok: true,
data: {
extensionId: bridge.extensionId,
public: bridge.public,
routeParams: bridge.routeParams,
query: bridge.query
}
})
return
}
if (message.action === 'api') {
sendResponse(message.id, {
ok: true,
data: await callApi(message)
})
return
}
if (message.action === 'ui.notify') {
notify(message)
sendResponse(message.id, {ok: true, data: {ok: true}})
return
}
throw new Error('Unknown extension bridge action.')
} catch (error) {
sendResponse(message.id, {
ok: false,
error: error instanceof Error ? error.message : String(error)
})
}
})
})()
</script>
{% endblock %}