feat: serve in iframe
This commit is contained in:
@@ -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 "
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user