feat: serve in iframe
This commit is contained in:
@@ -1,15 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from loguru import logger
|
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
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -48,7 +52,7 @@ def register_wasm_extension(app: FastAPI, ext_id: str) -> WasmExtension:
|
|||||||
|
|
||||||
warm_wasm_extension(loaded)
|
warm_wasm_extension(loaded)
|
||||||
_mount_wasm_extension_static(app, 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)
|
_register_wasm_extension_api_routes(app, loaded)
|
||||||
|
|
||||||
extensions = getattr(app.state, "lnbits_wasm_extensions", {})
|
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():
|
if not static_path.is_dir():
|
||||||
return
|
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):
|
if any(getattr(route, "path", None) == mount_path for route in app.routes):
|
||||||
return
|
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:
|
def _register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -> None:
|
||||||
for route_config in extension.config.get("routes") or []:
|
for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
|
||||||
route_path = _wasm_extension_route_path(extension, route_config.get("path"))
|
route_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
|
||||||
entrypoint = _wasm_extension_entrypoint(
|
entrypoint = _wasm_extension_entrypoint(
|
||||||
extension, route_config.get("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:
|
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"))
|
route_path = _wasm_extension_api_path(extension, route_config.get("path"))
|
||||||
export_name = _wasm_extension_api_export(extension, route_config.get("export"))
|
export_name = _wasm_extension_api_export(extension, route_config.get("export"))
|
||||||
path_params = route_config.get("path_params") or {}
|
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):
|
if _has_route(app, route_path, method):
|
||||||
return
|
return
|
||||||
@@ -158,6 +174,7 @@ def _add_wasm_extension_api_route(
|
|||||||
invoke_wasm_extension_export,
|
invoke_wasm_extension_export,
|
||||||
methods=[method],
|
methods=[method],
|
||||||
name=f"{extension.id}:{method}:{route_path}",
|
name=f"{extension.id}:{method}:{route_path}",
|
||||||
|
dependencies=_wasm_extension_dependencies(extension, auth),
|
||||||
include_in_schema=False,
|
include_in_schema=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -243,27 +260,176 @@ def _snake_to_camel(value: str) -> str:
|
|||||||
return head + "".join(part.capitalize() for part in tail)
|
return head + "".join(part.capitalize() for part in tail)
|
||||||
|
|
||||||
|
|
||||||
def _add_wasm_extension_page_route(
|
def _add_wasm_extension_wrapper_route(
|
||||||
app: FastAPI,
|
app: FastAPI,
|
||||||
extension: WasmExtension,
|
extension: WasmExtension,
|
||||||
route_path: str,
|
route_path: str,
|
||||||
entrypoint: Path,
|
frame_path: str,
|
||||||
|
auth: str,
|
||||||
|
path_params: dict[str, str],
|
||||||
) -> None:
|
) -> None:
|
||||||
if any(getattr(route, "path", None) == route_path for route in app.routes):
|
if _has_route(app, route_path, "GET"):
|
||||||
return
|
return
|
||||||
|
|
||||||
async def serve_wasm_extension_page() -> FileResponse:
|
require_user = _require_wasm_user_extension(extension.id)
|
||||||
return FileResponse(entrypoint)
|
|
||||||
|
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(
|
app.add_api_route(
|
||||||
route_path,
|
route_path,
|
||||||
serve_wasm_extension_page,
|
(
|
||||||
|
serve_public_wasm_extension_page
|
||||||
|
if auth == "public"
|
||||||
|
else serve_private_wasm_extension_page
|
||||||
|
),
|
||||||
methods=["GET"],
|
methods=["GET"],
|
||||||
name=f"{extension.id}:{route_path}",
|
name=f"{extension.id}:{route_path}",
|
||||||
include_in_schema=False,
|
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:
|
def _load_json(path: Path) -> dict[str, Any] | None:
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return None
|
return None
|
||||||
@@ -289,12 +455,18 @@ def _optional_extension_path(ext_dir: Path, value: Any) -> Path | None:
|
|||||||
return _extension_path(ext_dir, value)
|
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("/"):
|
if not isinstance(path, str) or not path.startswith("/"):
|
||||||
raise ValueError(f"Invalid route path for WASM extension '{extension.id}'.")
|
raise ValueError(f"Invalid route path for WASM extension '{extension.id}'.")
|
||||||
if path == "/":
|
if path == "/":
|
||||||
return f"/{extension.id}/"
|
return "/ext"
|
||||||
return f"/{extension.id}{path}"
|
return f"/ext{path}"
|
||||||
|
|
||||||
|
|
||||||
def _wasm_extension_entrypoint(extension: WasmExtension, entrypoint: Any) -> 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}'."
|
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):
|
if not entrypoint.startswith(static_prefix):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Route entrypoint for WASM extension '{extension.id}' must be under "
|
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