refactor: structure

This commit is contained in:
Vlad Stan
2026-07-08 11:55:15 +03:00
parent 4b8fca43cf
commit 3150ae7be1
30 changed files with 1074 additions and 952 deletions
+6 -6
View File
@@ -24,12 +24,6 @@ from lnbits.core.crud import (
update_installed_extension_state,
)
from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.extensions.events import dispatch_wasm_invoice_paid
from lnbits.core.extensions.loader import (
is_wasm_extension_dir,
is_wasm_extension_id,
)
from lnbits.core.extensions.routes import register_wasm_extension
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
@@ -44,6 +38,12 @@ from lnbits.core.tasks import (
wait_for_paid_invoices,
wait_notification_messages,
)
from lnbits.core.wasm_ext.routes.register import register_wasm_extension
from lnbits.core.wasm_ext.wasm.events import dispatch_wasm_invoice_paid
from lnbits.core.wasm_ext.wasm.loader import (
is_wasm_extension_dir,
is_wasm_extension_id,
)
from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse
from lnbits.llms_txt import create_llms_txt_route
-777
View File
@@ -1,777 +0,0 @@
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Annotated, Any, NoReturn
from uuid import uuid4
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from loguru import logger
from pydantic import UUID4
from starlette.staticfiles import PathLike as StaticFilesPathLike
from starlette.types import Scope
from lnbits.core.crud import get_installed_extension, get_user_from_account
from lnbits.core.db import core_app_extra
from lnbits.core.models import Account
from lnbits.decorators import (
check_access_token,
check_account_exists,
optional_user_id,
)
from lnbits.helpers import template_renderer
from lnbits.settings import settings
from lnbits.utils.cache import cache
from .loader import WasmExtension, load_wasm_extension
from .wasm import invoke_wasm_extension_export, warm_wasm_extension
WASM_FRAME_TOKEN_EXPIRY_SECONDS = 60
WASM_EXTENSION_CORE_ASSET_PREFIX = "_lnbits"
WASM_EXTENSION_CORE_STATIC_ASSETS = {
"bundle.min.css": ("static/bundle.min.css", "text/css; charset=utf-8"),
"material-icons-v50.woff2": (
"static/fonts/material-icons-v50.woff2",
"font/woff2",
),
"quasar.css": ("static/vendor/quasar.css", "text/css; charset=utf-8"),
"quasar.umd.prod.js": (
"static/vendor/quasar.umd.prod.js",
"text/javascript; charset=utf-8",
),
"qrcode.vue.browser.js": (
"static/vendor/qrcode.vue.browser.js",
"text/javascript; charset=utf-8",
),
"vue.global.prod.js": (
"static/vendor/vue.global.prod.js",
"text/javascript; charset=utf-8",
),
}
WASM_EXTENSION_GENERATED_CORE_ASSETS = {
"material-icons.css": (
"""
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url('./material-icons-v50.woff2') format('woff2');
}
""",
"text/css; charset=utf-8",
)
}
WASM_EXTENSION_STATIC_MIME_TYPES = {
".css": "text/css; charset=utf-8",
".gif": "image/gif",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".png": "image/png",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
}
WASM_EXTENSION_TEXT_STATIC_EXTENSIONS = {".css", ".js"}
WASM_EXTENSION_HTML_PREFIXES = (b"<!doctype", b"<html", b"<script")
class GuardedWasmExtensionStaticFiles(StaticFiles):
async def get_response(self, path: str, scope: Scope) -> Response:
if path.startswith(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/"):
return _wasm_extension_core_asset_response(path)
if Path(path).suffix.lower() not in WASM_EXTENSION_STATIC_MIME_TYPES:
raise HTTPException(status_code=404)
return await super().get_response(path, scope)
def file_response(
self,
full_path: StaticFilesPathLike,
stat_result: os.stat_result,
scope: Scope,
status_code: int = 200,
) -> Response:
suffix = Path(full_path).suffix.lower()
if suffix in WASM_EXTENSION_TEXT_STATIC_EXTENSIONS:
_reject_html_like_wasm_static_asset(Path(full_path))
response = super().file_response(full_path, stat_result, scope, status_code)
response.headers["Content-Type"] = WASM_EXTENSION_STATIC_MIME_TYPES[suffix]
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
return response
def register_wasm_extension(app: FastAPI, ext_id: str) -> WasmExtension:
loaded = load_wasm_extension(ext_id)
warm_wasm_extension(loaded)
_mount_wasm_extension_static(app, loaded)
_register_wasm_extension_ui_routes(app, loaded)
_register_wasm_extension_api_routes(app, loaded)
core_app_extra.wasm_extension_registry.register(loaded)
settings.activate_extension_paths(ext_id, "", [])
logger.info(
f"Loaded WASM extension '{loaded.id}' "
f"({loaded.module_path.stat().st_size} bytes)."
)
return loaded
def _mount_wasm_extension_static(app: FastAPI, extension: WasmExtension) -> None:
static_path = extension.root_path / "static"
mount_path = f"/ext-assets/{extension.id}"
if any(getattr(route, "path", None) == mount_path for route in app.routes):
return
app.mount(
mount_path,
GuardedWasmExtensionStaticFiles(directory=static_path, check_dir=False),
name=f"{extension.id}-static",
)
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(
extension, route_config.get("entrypoint")
)
frame_path = f"/ext-frame/{extension.id}/{route_index}"
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
_add_wasm_extension_wrapper_route(
app,
extension,
route_path,
auth,
)
def _register_wasm_extension_api_routes(app: FastAPI, extension: WasmExtension) -> None:
for route_config in extension.config.get("api_routes") or []:
_add_wasm_extension_api_route(app, extension, route_config)
def _add_wasm_extension_api_route(
app: FastAPI,
extension: WasmExtension,
route_config: dict[str, Any],
) -> None:
method = _wasm_extension_api_method(extension, route_config.get("method"))
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
async def invoke_wasm_api_request(
request: Request,
account: Account | None = None,
access_token: str | None = None,
) -> dict[str, Any]:
try:
payload = await _read_api_payload(request, path_params)
return await invoke_wasm_extension_export(
extension.id,
export_name,
payload,
user=account,
access_token=access_token,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
async def invoke_private_wasm_extension_export(
request: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
account: Account = Depends(check_account_exists),
) -> dict[str, Any]:
return await invoke_wasm_api_request(request, account, access_token)
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_public_wasm_extension_export
if auth == "public"
else invoke_private_wasm_extension_export
),
methods=[method],
name=f"{extension.id}:{method}:{route_path}",
include_in_schema=False,
)
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)
granted_permission_ids = await _wasm_extension_granted_permission_ids(extension)
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,
granted_permission_ids,
)
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],
) -> dict[str, Any]:
payload = _read_api_path_params(request, path_params)
payload.update(_read_api_query_params(request))
if request.method in {"POST", "PUT", "PATCH"}:
payload.update(await _read_json_object(request))
return payload
async def _read_json_object(request: Request) -> dict[str, Any]:
body = await request.body()
if not body:
return {}
value = json.loads(body)
if not isinstance(value, dict):
raise TypeError("WASM extension API payload must be a JSON object.")
return value
def _read_api_path_params(
request: Request,
path_params: dict[str, str],
) -> dict[str, Any]:
payload: dict[str, Any] = {}
for key, value in request.path_params.items():
target = path_params.get(key) or _snake_to_camel(key)
payload[target] = value
return payload
def _read_api_query_params(request: Request) -> dict[str, Any]:
return {_snake_to_camel(key): value for key, value in request.query_params.items()}
def _wasm_extension_api_export(extension: WasmExtension, export_name: Any) -> str:
if not isinstance(export_name, str) or not export_name:
raise ValueError(f"Invalid API export for WASM extension '{extension.id}'.")
for export in extension.exports:
if export.get("name") != export_name:
continue
if export.get("visibility") in {"public", "authenticated"}:
return export_name
raise PermissionError(f"WASM export '{export_name}' is not callable over HTTP.")
raise KeyError(f"WASM extension '{extension.id}' has no export '{export_name}'.")
def _wasm_extension_api_method(extension: WasmExtension, method: Any) -> str:
if not isinstance(method, str):
raise ValueError(f"Invalid API method for WASM extension '{extension.id}'.")
method = method.upper()
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
raise ValueError(f"Unsupported API method for WASM extension '{extension.id}'.")
return method
def _wasm_extension_api_path(extension: WasmExtension, path: Any) -> str:
if not isinstance(path, str) or not path.startswith("/"):
raise ValueError(f"Invalid API path for WASM extension '{extension.id}'.")
if path == "/":
return f"/api/v1/ext/{extension.id}"
return f"/api/v1/ext/{extension.id}{path}"
def _has_route(app: FastAPI, route_path: str, method: str) -> bool:
for route in app.routes:
if getattr(route, "path", None) != route_path:
continue
methods = getattr(route, "methods", set()) or set()
if method in methods:
return True
return False
def _snake_to_camel(value: str) -> str:
head, *tail = value.split("_")
return head + "".join(part.capitalize() for part in tail)
def _add_wasm_extension_wrapper_route(
app: FastAPI,
extension: WasmExtension,
route_path: str,
auth: str,
) -> None:
if _has_route(app, route_path, "GET"):
return
async def serve_private_wasm_extension_page(
request: Request,
account: Account = Depends(check_account_exists),
) -> Any:
user = await get_user_from_account(account)
return _wasm_extension_wrapper_response(
request,
extension,
auth,
user.json() if user else None,
)
async def serve_public_wasm_extension_page(request: Request) -> Any:
return _wasm_extension_wrapper_response(
request,
extension,
auth,
None,
)
app.add_api_route(
route_path,
(
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,
) -> None:
if _has_route(app, frame_path, "GET"):
return
async def serve_wasm_extension_frame(
request: Request,
user_id: str | None = Depends(_optional_wasm_user_id),
) -> FileResponse:
_consume_wasm_extension_frame_token(request, extension, frame_path, user_id)
response = FileResponse(entrypoint)
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"
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
# Extension access goes through the parent bridge.
response.headers["Permissions-Policy"] = (
"camera=(), microphone=(), geolocation=(), payment=(), "
"clipboard-read=(), usb=()"
)
response.headers["Referrer-Policy"] = "no-referrer"
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}",
include_in_schema=False,
)
def _wasm_extension_wrapper_response(
request: Request,
extension: WasmExtension,
auth: str,
user_json: str | None,
) -> Any:
public = auth == "public"
response = template_renderer().TemplateResponse(
request,
"wasm_extension.html",
{
"extension": extension,
"public": public,
"user": user_json,
},
)
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
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}/"
return (
"sandbox allow-scripts; "
"default-src 'none'; "
f"script-src {extension_assets}; "
"script-src-attr 'none'; "
f"style-src {extension_assets}; "
"style-src-attr 'none'; "
f"img-src {extension_assets} data:; "
f"font-src {extension_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, user_id: str | None
) -> str:
token = _create_wasm_extension_frame_token(extension, frame_path, user_id)
return f"{frame_path}?frame_token={token}"
def _create_wasm_extension_frame_token(
extension: WasmExtension,
frame_path: str,
user_id: str | None,
) -> str:
token = uuid4().hex
cache.set(
_wasm_extension_frame_token_cache_key(token),
{
"extension_id": extension.id,
"frame_path": frame_path,
"user_id": user_id,
},
expiry=WASM_FRAME_TOKEN_EXPIRY_SECONDS,
)
return token
def _consume_wasm_extension_frame_token(
request: Request,
extension: WasmExtension,
frame_path: str,
user_id: str | None,
) -> None:
token = request.query_params.get("frame_token")
if not token:
_raise_wasm_extension_frame_not_found(extension, frame_path, "missing")
cache_key = _wasm_extension_frame_token_cache_key(token)
token_data = cache.get(cache_key)
if (
not isinstance(token_data, dict)
or token_data.get("extension_id") != extension.id
or token_data.get("frame_path") != frame_path
):
_raise_wasm_extension_frame_not_found(
extension, frame_path, "unknown or expired"
)
token_user_id = token_data.get("user_id")
if token_user_id and token_user_id != user_id:
_raise_wasm_extension_frame_not_found(extension, frame_path, "wrong user")
cache.pop(cache_key)
def _wasm_extension_frame_token_cache_key(token: str) -> str:
return f"wasm-frame-token:{token}"
def _raise_wasm_extension_frame_not_found(
extension: WasmExtension,
frame_path: str,
reason: str,
) -> NoReturn:
logger.warning(
f"WASM frame token {reason} for extension '{extension.id}' at '{frame_path}'."
)
raise HTTPException(status_code=404, detail="Not found")
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 _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,
permissions: set[str],
) -> 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,
"permissions": sorted(permissions),
"apiRoutes": _wasm_extension_bridge_api_routes(extension, public),
},
}
async def _wasm_extension_granted_permission_ids(
extension: WasmExtension,
) -> set[str]:
installed_extension = await get_installed_extension(extension.id)
if not installed_extension:
return set()
return {permission.id for permission in installed_extension.permissions}
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}$"
async def _optional_wasm_user_id(
request: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> str | None:
try:
return await optional_user_id(request, access_token, usr)
except HTTPException:
return None
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 "/ext"
return f"/ext{path}"
def _wasm_extension_entrypoint(extension: WasmExtension, entrypoint: Any) -> Path:
if not isinstance(entrypoint, str) or not entrypoint:
raise ValueError(
f"Invalid route entrypoint for WASM extension '{extension.id}'."
)
if entrypoint.startswith("/"):
raise ValueError(
f"Route entrypoint for WASM extension '{extension.id}' must be a "
"relative extension path."
)
path = (extension.root_path / entrypoint).resolve()
root_path = extension.root_path.resolve()
if path != root_path and root_path not in path.parents:
raise ValueError(f"Route entrypoint escapes extension root: {entrypoint}")
static_path = (extension.root_path / "static").resolve()
if path == static_path or static_path in path.parents:
raise ValueError(
f"Route entrypoint for WASM extension '{extension.id}' must not be "
"inside the static asset directory."
)
if path.suffix.lower() != ".html":
raise ValueError(
f"Route entrypoint for WASM extension '{extension.id}' must be "
"an HTML file."
)
if not path.is_file():
raise FileNotFoundError(f"Route entrypoint not found: {path}")
return path
def _reject_html_like_wasm_static_asset(path: Path) -> None:
with path.open("rb") as asset_file:
prefix = asset_file.read(512).lstrip().lower()
if prefix.startswith(WASM_EXTENSION_HTML_PREFIXES):
raise HTTPException(status_code=404)
def _wasm_extension_core_asset_response(path: str) -> Response:
asset_name = path.removeprefix(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/")
if not asset_name or "/" in asset_name or "\\" in asset_name:
raise HTTPException(status_code=404)
generated_asset = WASM_EXTENSION_GENERATED_CORE_ASSETS.get(asset_name)
if generated_asset:
content, content_type = generated_asset
response = Response(content=content, media_type=content_type)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
return response
asset_config = WASM_EXTENSION_CORE_STATIC_ASSETS.get(asset_name)
if not asset_config:
raise HTTPException(status_code=404)
relative_path, content_type = asset_config
asset_path = Path(settings.lnbits_path, relative_path)
if not asset_path.is_file():
raise HTTPException(status_code=404)
response = FileResponse(asset_path)
response.headers["Content-Type"] = content_type
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
return response
+2 -2
View File
@@ -13,10 +13,10 @@ from lnbits.core.crud import (
update_migration_version,
)
from lnbits.core.db import db as core_db
from lnbits.core.extensions.loader import is_wasm_extension_id
from lnbits.core.extensions.storage import migrate_wasm_extension_database
from lnbits.core.models import DbVersion
from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.wasm_ext.storage.crud import migrate_wasm_extension_database
from lnbits.core.wasm_ext.wasm.loader import is_wasm_extension_id
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
from lnbits.settings import settings
+1 -1
View File
@@ -16,8 +16,8 @@ from lnbits.core.crud.extensions import (
get_installed_extensions,
update_installed_extension,
)
from lnbits.core.extensions.permissions import validate_wasm_extension_permissions
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
from lnbits.db import Connection
from lnbits.settings import settings
+1 -1
View File
@@ -11,7 +11,6 @@ from loguru import logger
from lnbits.core.crud.extensions import get_user_extensions
from lnbits.core.crud.wallets import get_wallets_ids
from lnbits.core.db import db
from lnbits.core.extensions.permissions import validate_extension_permissions
from lnbits.core.models import (
SimpleStatus,
)
@@ -42,6 +41,7 @@ from lnbits.core.services.extensions import (
install_extension,
uninstall_extension,
)
from lnbits.core.wasm_ext.api.permissions import validate_extension_permissions
from lnbits.db import Page
from lnbits.decorators import (
check_account_exists,
+21
View File
@@ -0,0 +1,21 @@
from .api.host import (
ExtensionAPIMethod,
ExtensionHostAPI,
extension_api_contract,
extension_api_method,
get_extension_api_method,
list_extension_api_methods,
)
from .api.runtime import ExtensionAPIHost
from .wasm.loader import WasmExtension
__all__ = [
"ExtensionAPIHost",
"ExtensionAPIMethod",
"ExtensionHostAPI",
"WasmExtension",
"extension_api_contract",
"extension_api_method",
"get_extension_api_method",
"list_extension_api_methods",
]
@@ -1,28 +1,19 @@
"""Extension runtime contracts."""
from .api import (
ExtensionAPI,
from .host import (
ExtensionAPIMethod,
ExtensionHostAPI,
extension_api_contract,
extension_api_method,
get_extension_api_method,
list_extension_api_methods,
)
from .loader import WasmExtension, load_wasm_extension
from .routes import register_wasm_extension
from .runtime import ExtensionAPIHost
from .wasm import invoke_wasm_extension_export
__all__ = [
"ExtensionAPI",
"ExtensionAPIHost",
"ExtensionAPIMethod",
"WasmExtension",
"ExtensionHostAPI",
"extension_api_contract",
"extension_api_method",
"get_extension_api_method",
"invoke_wasm_extension_export",
"list_extension_api_methods",
"load_wasm_extension",
"register_wasm_extension",
]
@@ -14,6 +14,13 @@ from pydantic import BaseModel
from lnbits.helpers import sha256s
from ..storage.crud import (
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_set_row,
)
from .models import (
CreateInvoicePublicRequest,
CreateInvoiceRequest,
@@ -42,13 +49,6 @@ from .models import (
WalletBalanceRequest,
WalletBalanceResponse,
)
from .storage import (
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_set_row,
)
logger = logging.getLogger("lnbits.extensions")
@@ -137,7 +137,7 @@ def extension_api_method(
return decorator
class ExtensionAPI:
class ExtensionHostAPI:
def __init__(
self,
extension_id: str,
@@ -155,13 +155,13 @@ class ExtensionAPI:
self.context = context
self.owner_id = sha256s(user_id) if user_id else owner_id
self._uuid = secrets.token_urlsafe(12).replace("-", "_")
from .api_utils import ExtensionAPIUtils
from .utils import ExtensionAPIUtils
self.utils = ExtensionAPIUtils(self)
def __repr__(self) -> str:
return (
"ExtensionAPI("
"ExtensionHostAPI("
f"extension_id={self.extension_id!r}, "
f"context={self.context!r}, "
f"_uuid={self._uuid!r}"
@@ -512,7 +512,7 @@ class ExtensionAPI:
require_auth=True,
)
async def http_request(self, request: HttpRequest) -> HttpResponse:
from .http_client import send_extension_http_request
from ..client.http import send_extension_http_request
policy = self.permission_policies.get("http.request") or {}
return await send_extension_http_request(self.extension_id, policy, request)
@@ -528,7 +528,7 @@ class ExtensionAPI:
require_auth=True,
)
async def extension_api_request(self, request: ExtensionApiRequest) -> HttpResponse:
from .extension_client import send_extension_api_request
from ..client.extensions import send_extension_api_request
policy = self.permission_policies.get("extension.api.request") or {}
return await send_extension_api_request(
@@ -649,7 +649,7 @@ class ExtensionAPI:
def list_extension_api_methods(
api_cls: type[ExtensionAPI] = ExtensionAPI,
api_cls: type[ExtensionHostAPI] = ExtensionHostAPI,
) -> list[ExtensionAPIMethod]:
methods: list[ExtensionAPIMethod] = []
@@ -681,18 +681,18 @@ def list_extension_api_methods(
def _extension_api_method_sources(
api_cls: type[ExtensionAPI],
api_cls: type[ExtensionHostAPI],
) -> list[tuple[str, type[Any]]]:
sources: list[tuple[str, type[Any]]] = [("", api_cls)]
if issubclass(api_cls, ExtensionAPI):
from .api_utils import extension_api_utils_method_classes
if issubclass(api_cls, ExtensionHostAPI):
from .utils import extension_api_utils_method_classes
sources.extend(extension_api_utils_method_classes().items())
return sources
def extension_api_permission_ids(
api_cls: type[ExtensionAPI] = ExtensionAPI,
api_cls: type[ExtensionHostAPI] = ExtensionHostAPI,
) -> set[str]:
permissions = {
method.required_permission
@@ -705,7 +705,7 @@ def extension_api_permission_ids(
def get_extension_api_method(
method_id: str,
api_cls: type[ExtensionAPI] = ExtensionAPI,
api_cls: type[ExtensionHostAPI] = ExtensionHostAPI,
) -> ExtensionAPIMethod:
for method in list_extension_api_methods(api_cls):
if method.method_id == method_id:
@@ -714,7 +714,7 @@ def get_extension_api_method(
def extension_api_contract(
api_cls: type[ExtensionAPI] = ExtensionAPI,
api_cls: type[ExtensionHostAPI] = ExtensionHostAPI,
) -> dict[str, object]:
return {
"version": 1,
@@ -1,8 +1,8 @@
from collections.abc import Iterable
from typing import Any
from lnbits.core.extensions.api import extension_api_permission_ids
from lnbits.core.models.extensions import ExtensionPermission, InstallableExtension
from lnbits.core.wasm_ext.api.host import extension_api_permission_ids
def validate_extension_permissions(
@@ -7,7 +7,7 @@ from typing import Any
from pydantic import BaseModel
from .api import ExtensionAPI, ExtensionAPIMethod, list_extension_api_methods
from .host import ExtensionAPIMethod, ExtensionHostAPI, list_extension_api_methods
HostImport = Callable[..., Awaitable[dict[str, Any]]]
@@ -15,9 +15,9 @@ HostImport = Callable[..., Awaitable[dict[str, Any]]]
class ExtensionAPIHost:
def __init__(
self,
api: ExtensionAPI,
api: ExtensionHostAPI,
*,
api_cls: type[ExtensionAPI] = ExtensionAPI,
api_cls: type[ExtensionHostAPI] = ExtensionHostAPI,
) -> None:
self.api = api
self.methods = list_extension_api_methods(api_cls)
@@ -4,7 +4,7 @@ import time
from datetime import datetime
from typing import TYPE_CHECKING, Any
from .api import extension_api_method
from .host import extension_api_method
from .models import (
Bolt11Request,
CurrencyConvertRequest,
@@ -31,11 +31,11 @@ from .models import (
)
if TYPE_CHECKING:
from .api import ExtensionAPI
from .host import ExtensionHostAPI
class ExtensionAPIUtils:
def __init__(self, api: ExtensionAPI) -> None:
def __init__(self, api: ExtensionHostAPI) -> None:
self.api = api
self.currencies = ExtensionCurrencyUtils(api)
self.server = ExtensionServerUtils(api)
@@ -43,7 +43,7 @@ class ExtensionAPIUtils:
class _ExtensionAPIUtilsGroup:
def __init__(self, api: ExtensionAPI) -> None:
def __init__(self, api: ExtensionHostAPI) -> None:
self.api = api
+4
View File
@@ -0,0 +1,4 @@
from .extensions import send_extension_api_request
from .http import send_extension_http_request
__all__ = ["send_extension_api_request", "send_extension_http_request"]
@@ -13,7 +13,7 @@ from lnbits.core.crud.extensions import (
)
from lnbits.settings import settings
from .models import ExtensionApiRequest, HttpResponse
from ..api.models import ExtensionApiRequest, HttpResponse
EXTENSION_API_TIMEOUT_SECONDS = 10.0
EXTENSION_API_MAX_RESPONSE_BYTES = 262_144
@@ -7,7 +7,7 @@ from urllib.parse import urlparse
import httpx
from .models import HttpRequest, HttpResponse
from ..api.models import HttpRequest, HttpResponse
HTTP_REQUEST_TIMEOUT_SECONDS = 10.0
HTTP_MAX_RESPONSE_BYTES = 262_144
+3
View File
@@ -0,0 +1,3 @@
from .register import register_wasm_extension
__all__ = ["register_wasm_extension"]
+168
View File
@@ -0,0 +1,168 @@
from __future__ import annotations
import json
import re
from typing import Annotated, Any
from fastapi import Depends, FastAPI, HTTPException, Request
from lnbits.core.models import Account
from lnbits.decorators import check_access_token, check_account_exists
from ..wasm.invoke import invoke_wasm_extension_export
from ..wasm.loader import WasmExtension
def register_wasm_extension_api_routes(app: FastAPI, extension: WasmExtension) -> None:
for route_config in extension.config.get("api_routes") or []:
_add_wasm_extension_api_route(app, extension, route_config)
def _add_wasm_extension_api_route(
app: FastAPI,
extension: WasmExtension,
route_config: dict[str, Any],
) -> None:
method = _wasm_extension_api_method(extension, route_config.get("method"))
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
async def invoke_wasm_api_request(
request: Request,
account: Account | None = None,
access_token: str | None = None,
) -> dict[str, Any]:
try:
payload = await _read_api_payload(request, path_params)
return await invoke_wasm_extension_export(
extension.id,
export_name,
payload,
user=account,
access_token=access_token,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
async def invoke_private_wasm_extension_export(
request: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
account: Account = Depends(check_account_exists),
) -> dict[str, Any]:
return await invoke_wasm_api_request(request, account, access_token)
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_public_wasm_extension_export
if auth == "public"
else invoke_private_wasm_extension_export
),
methods=[method],
name=f"{extension.id}:{method}:{route_path}",
include_in_schema=False,
)
async def _read_api_payload(
request: Request,
path_params: dict[str, str],
) -> dict[str, Any]:
payload = _read_api_path_params(request, path_params)
payload.update(_read_api_query_params(request))
if request.method in {"POST", "PUT", "PATCH"}:
payload.update(await _read_json_object(request))
return payload
async def _read_json_object(request: Request) -> dict[str, Any]:
body = await request.body()
if not body:
return {}
value = json.loads(body)
if not isinstance(value, dict):
raise TypeError("WASM extension API payload must be a JSON object.")
return value
def _read_api_path_params(
request: Request,
path_params: dict[str, str],
) -> dict[str, Any]:
payload: dict[str, Any] = {}
for key, value in request.path_params.items():
target = path_params.get(key) or _snake_to_camel(key)
payload[target] = value
return payload
def _read_api_query_params(request: Request) -> dict[str, Any]:
return {_snake_to_camel(key): value for key, value in request.query_params.items()}
def _wasm_extension_api_export(extension: WasmExtension, export_name: Any) -> str:
if not isinstance(export_name, str) or not export_name:
raise ValueError(f"Invalid API export for WASM extension '{extension.id}'.")
for export in extension.exports:
if export.get("name") != export_name:
continue
if export.get("visibility") in {"public", "authenticated"}:
return export_name
raise PermissionError(f"WASM export '{export_name}' is not callable over HTTP.")
raise KeyError(f"WASM extension '{extension.id}' has no export '{export_name}'.")
def _wasm_extension_api_method(extension: WasmExtension, method: Any) -> str:
if not isinstance(method, str):
raise ValueError(f"Invalid API method for WASM extension '{extension.id}'.")
method = method.upper()
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
raise ValueError(f"Unsupported API method for WASM extension '{extension.id}'.")
return method
def _wasm_extension_api_path(extension: WasmExtension, path: Any) -> str:
if not isinstance(path, str) or not path.startswith("/"):
raise ValueError(f"Invalid API path for WASM extension '{extension.id}'.")
if path == "/":
return f"/api/v1/ext/{extension.id}"
return f"/api/v1/ext/{extension.id}{path}"
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 _has_route(app: FastAPI, route_path: str, method: str) -> bool:
for route in app.routes:
if getattr(route, "path", None) != route_path:
continue
methods = getattr(route, "methods", set()) or set()
if method in methods:
return True
return False
def _snake_to_camel(value: str) -> str:
head, *tail = value.split("_")
return head + "".join(part.capitalize() for part in tail)
def _path_template_pattern(path: str) -> str:
pattern = re.sub(r"\\{[^/{}]+\\}", r"[^/]+", re.escape(path))
return f"^{pattern}$"
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import os
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from starlette.staticfiles import PathLike as StaticFilesPathLike
from starlette.types import Scope
from lnbits.settings import settings
from ..wasm.loader import WasmExtension
WASM_EXTENSION_CORE_ASSET_PREFIX = "_lnbits"
WASM_EXTENSION_CORE_STATIC_ASSETS = {
"bundle.min.css": ("static/bundle.min.css", "text/css; charset=utf-8"),
"material-icons-v50.woff2": (
"static/fonts/material-icons-v50.woff2",
"font/woff2",
),
"quasar.css": ("static/vendor/quasar.css", "text/css; charset=utf-8"),
"quasar.umd.prod.js": (
"static/vendor/quasar.umd.prod.js",
"text/javascript; charset=utf-8",
),
"qrcode.vue.browser.js": (
"static/vendor/qrcode.vue.browser.js",
"text/javascript; charset=utf-8",
),
"vue.global.prod.js": (
"static/vendor/vue.global.prod.js",
"text/javascript; charset=utf-8",
),
}
WASM_EXTENSION_GENERATED_CORE_ASSETS = {
"material-icons.css": (
"""
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
src: url('./material-icons-v50.woff2') format('woff2');
}
""",
"text/css; charset=utf-8",
)
}
WASM_EXTENSION_STATIC_MIME_TYPES = {
".css": "text/css; charset=utf-8",
".gif": "image/gif",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".png": "image/png",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
}
WASM_EXTENSION_TEXT_STATIC_EXTENSIONS = {".css", ".js"}
WASM_EXTENSION_HTML_PREFIXES = (b"<!doctype", b"<html", b"<script")
class GuardedWasmExtensionStaticFiles(StaticFiles):
async def get_response(self, path: str, scope: Scope) -> Response:
if path.startswith(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/"):
return _wasm_extension_core_asset_response(path)
if Path(path).suffix.lower() not in WASM_EXTENSION_STATIC_MIME_TYPES:
raise HTTPException(status_code=404)
return await super().get_response(path, scope)
def file_response(
self,
full_path: StaticFilesPathLike,
stat_result: os.stat_result,
scope: Scope,
status_code: int = 200,
) -> Response:
suffix = Path(full_path).suffix.lower()
if suffix in WASM_EXTENSION_TEXT_STATIC_EXTENSIONS:
_reject_html_like_wasm_static_asset(Path(full_path))
response = super().file_response(full_path, stat_result, scope, status_code)
response.headers["Content-Type"] = WASM_EXTENSION_STATIC_MIME_TYPES[suffix]
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
return response
def mount_wasm_extension_static(app: FastAPI, extension: WasmExtension) -> None:
static_path = extension.root_path / "static"
mount_path = f"/ext-assets/{extension.id}"
if any(getattr(route, "path", None) == mount_path for route in app.routes):
return
app.mount(
mount_path,
GuardedWasmExtensionStaticFiles(directory=static_path, check_dir=False),
name=f"{extension.id}-static",
)
def _reject_html_like_wasm_static_asset(path: Path) -> None:
with path.open("rb") as asset_file:
prefix = asset_file.read(512).lstrip().lower()
if prefix.startswith(WASM_EXTENSION_HTML_PREFIXES):
raise HTTPException(status_code=404)
def _wasm_extension_core_asset_response(path: str) -> Response:
asset_name = path.removeprefix(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/")
if not asset_name or "/" in asset_name or "\\" in asset_name:
raise HTTPException(status_code=404)
generated_asset = WASM_EXTENSION_GENERATED_CORE_ASSETS.get(asset_name)
if generated_asset:
content, content_type = generated_asset
response = Response(content=content, media_type=content_type)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
return response
asset_config = WASM_EXTENSION_CORE_STATIC_ASSETS.get(asset_name)
if not asset_config:
raise HTTPException(status_code=404)
relative_path, content_type = asset_config
asset_path = Path(settings.lnbits_path, relative_path)
if not asset_path.is_file():
raise HTTPException(status_code=404)
response = FileResponse(asset_path)
response.headers["Content-Type"] = content_type
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
return response
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
from fastapi import FastAPI
from loguru import logger
from lnbits.core.db import core_app_extra
from lnbits.settings import settings
from ..wasm.component import warm_wasm_extension
from ..wasm.loader import WasmExtension, load_wasm_extension
from .api import register_wasm_extension_api_routes
from .assets import mount_wasm_extension_static
from .ui import register_wasm_extension_ui_routes
def register_wasm_extension(app: FastAPI, ext_id: str) -> WasmExtension:
loaded = load_wasm_extension(ext_id)
warm_wasm_extension(loaded)
mount_wasm_extension_static(app, loaded)
register_wasm_extension_ui_routes(app, loaded)
register_wasm_extension_api_routes(app, loaded)
core_app_extra.wasm_extension_registry.register(loaded)
settings.activate_extension_paths(ext_id, "", [])
logger.info(
f"Loaded WASM extension '{loaded.id}' "
f"({loaded.module_path.stat().st_size} bytes)."
)
return loaded
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
from typing import Any, NoReturn
from uuid import uuid4
from fastapi import HTTPException, Request
from loguru import logger
from lnbits.helpers import template_renderer
from lnbits.utils.cache import cache
from ..wasm.loader import WasmExtension
WASM_FRAME_TOKEN_EXPIRY_SECONDS = 60
def wasm_extension_wrapper_response(
request: Request,
extension: WasmExtension,
auth: str,
user_json: str | None,
) -> Any:
public = auth == "public"
response = template_renderer().TemplateResponse(
request,
"wasm_extension.html",
{
"extension": extension,
"public": public,
"user": user_json,
},
)
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
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}/"
return (
"sandbox allow-scripts; "
"default-src 'none'; "
f"script-src {extension_assets}; "
"script-src-attr 'none'; "
f"style-src {extension_assets}; "
"style-src-attr 'none'; "
f"img-src {extension_assets} data:; "
f"font-src {extension_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, user_id: str | None
) -> str:
token = _create_wasm_extension_frame_token(extension, frame_path, user_id)
return f"{frame_path}?frame_token={token}"
def consume_wasm_extension_frame_token(
request: Request,
extension: WasmExtension,
frame_path: str,
user_id: str | None,
) -> None:
token = request.query_params.get("frame_token")
if not token:
_raise_wasm_extension_frame_not_found(extension, frame_path, "missing")
cache_key = _wasm_extension_frame_token_cache_key(token)
token_data = cache.get(cache_key)
if (
not isinstance(token_data, dict)
or token_data.get("extension_id") != extension.id
or token_data.get("frame_path") != frame_path
):
_raise_wasm_extension_frame_not_found(
extension, frame_path, "unknown or expired"
)
token_user_id = token_data.get("user_id")
if token_user_id and token_user_id != user_id:
_raise_wasm_extension_frame_not_found(extension, frame_path, "wrong user")
cache.pop(cache_key)
def _create_wasm_extension_frame_token(
extension: WasmExtension,
frame_path: str,
user_id: str | None,
) -> str:
token = uuid4().hex
cache.set(
_wasm_extension_frame_token_cache_key(token),
{
"extension_id": extension.id,
"frame_path": frame_path,
"user_id": user_id,
},
expiry=WASM_FRAME_TOKEN_EXPIRY_SECONDS,
)
return token
def _wasm_extension_frame_token_cache_key(token: str) -> str:
return f"wasm-frame-token:{token}"
def _raise_wasm_extension_frame_not_found(
extension: WasmExtension,
frame_path: str,
reason: str,
) -> NoReturn:
logger.warning(
f"WASM frame token {reason} for extension '{extension.id}' at '{frame_path}'."
)
raise HTTPException(status_code=404, detail="Not found")
+368
View File
@@ -0,0 +1,368 @@
from __future__ import annotations
from pathlib import Path
from typing import Annotated, Any
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import UUID4
from lnbits.core.crud import get_installed_extension, get_user_from_account
from lnbits.core.models import Account
from lnbits.decorators import (
check_access_token,
check_account_exists,
optional_user_id,
)
from ..wasm.loader import WasmExtension
from .api import (
_has_route,
_path_template_pattern,
_read_json_object,
_snake_to_camel,
_wasm_extension_api_export,
_wasm_extension_api_method,
_wasm_extension_api_path,
_wasm_extension_route_auth,
)
from .security import (
consume_wasm_extension_frame_token,
wasm_extension_frame_csp,
wasm_extension_frame_url,
wasm_extension_wrapper_response,
)
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(
extension, route_config.get("entrypoint")
)
frame_path = f"/ext-frame/{extension.id}/{route_index}"
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
_add_wasm_extension_wrapper_route(
app,
extension,
route_path,
auth,
)
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)
granted_permission_ids = await _wasm_extension_granted_permission_ids(extension)
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,
granted_permission_ids,
)
app.add_api_route(
route_path,
create_wasm_extension_frame_config,
methods=["POST"],
name=f"{extension.id}:frame-config",
include_in_schema=False,
)
def _add_wasm_extension_wrapper_route(
app: FastAPI,
extension: WasmExtension,
route_path: str,
auth: str,
) -> None:
if _has_route(app, route_path, "GET"):
return
async def serve_private_wasm_extension_page(
request: Request,
account: Account = Depends(check_account_exists),
) -> Any:
user = await get_user_from_account(account)
return wasm_extension_wrapper_response(
request,
extension,
auth,
user.json() if user else None,
)
async def serve_public_wasm_extension_page(request: Request) -> Any:
return wasm_extension_wrapper_response(
request,
extension,
auth,
None,
)
app.add_api_route(
route_path,
(
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,
) -> None:
if _has_route(app, frame_path, "GET"):
return
async def serve_wasm_extension_frame(
request: Request,
user_id: str | None = Depends(_optional_wasm_user_id),
) -> FileResponse:
consume_wasm_extension_frame_token(request, extension, frame_path, user_id)
response = FileResponse(entrypoint)
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"
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
# Extension access goes through the parent bridge.
response.headers["Permissions-Policy"] = (
"camera=(), microphone=(), geolocation=(), payment=(), "
"clipboard-read=(), usb=()"
)
response.headers["Referrer-Policy"] = "no-referrer"
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}",
include_in_schema=False,
)
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 _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,
permissions: set[str],
) -> 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,
"permissions": sorted(permissions),
"apiRoutes": _wasm_extension_bridge_api_routes(extension, public),
},
}
async def _wasm_extension_granted_permission_ids(
extension: WasmExtension,
) -> set[str]:
installed_extension = await get_installed_extension(extension.id)
if not installed_extension:
return set()
return {permission.id for permission in installed_extension.permissions}
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
async def _optional_wasm_user_id(
request: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> str | None:
try:
return await optional_user_id(request, access_token, usr)
except HTTPException:
return None
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 "/ext"
return f"/ext{path}"
def _wasm_extension_entrypoint(extension: WasmExtension, entrypoint: Any) -> Path:
if not isinstance(entrypoint, str) or not entrypoint:
raise ValueError(
f"Invalid route entrypoint for WASM extension '{extension.id}'."
)
if entrypoint.startswith("/"):
raise ValueError(
f"Route entrypoint for WASM extension '{extension.id}' must be a "
"relative extension path."
)
path = (extension.root_path / entrypoint).resolve()
root_path = extension.root_path.resolve()
if path != root_path and root_path not in path.parents:
raise ValueError(f"Route entrypoint escapes extension root: {entrypoint}")
static_path = (extension.root_path / "static").resolve()
if path == static_path or static_path in path.parents:
raise ValueError(
f"Route entrypoint for WASM extension '{extension.id}' must not be "
"inside the static asset directory."
)
if path.suffix.lower() != ".html":
raise ValueError(
f"Route entrypoint for WASM extension '{extension.id}' must be "
"an HTML file."
)
if not path.is_file():
raise FileNotFoundError(f"Route entrypoint not found: {path}")
return path
+19
View File
@@ -0,0 +1,19 @@
from .crud import (
migrate_wasm_extension_database,
storage_delete_row,
storage_get_paginated_rows,
storage_get_public_row,
storage_get_row,
storage_get_row_owner_id,
storage_set_row,
)
__all__ = [
"migrate_wasm_extension_database",
"storage_delete_row",
"storage_get_paginated_rows",
"storage_get_public_row",
"storage_get_row",
"storage_get_row_owner_id",
"storage_set_row",
]
+13
View File
@@ -0,0 +1,13 @@
from .component import warm_wasm_extension
from .events import dispatch_wasm_invoice_paid
from .invoke import invoke_wasm_extension_export
from .loader import WasmExtension, is_wasm_extension_dir, is_wasm_extension_id
__all__ = [
"WasmExtension",
"dispatch_wasm_invoice_paid",
"invoke_wasm_extension_export",
"is_wasm_extension_dir",
"is_wasm_extension_id",
"warm_wasm_extension",
]
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from functools import lru_cache
from typing import Any
from .loader import WasmExtension
def warm_wasm_extension(extension: WasmExtension) -> None:
_wasm_component(extension)
@lru_cache(maxsize=1)
def _wasm_engine() -> Any:
try:
from wasmtime import Config, Engine
except ImportError as exc:
raise RuntimeError(
"WASM extension runtime is not installed. Install the 'wasmtime' "
"Python package to run WASM extensions."
) from exc
config = Config()
config.wasm_component_model = True
return Engine(config)
def _wasm_component(extension: WasmExtension) -> Any:
stat = extension.module_path.stat()
return _cached_wasm_component(
str(extension.module_path),
stat.st_mtime_ns,
stat.st_size,
)
@lru_cache(maxsize=32)
def _cached_wasm_component(
module_path: str,
mtime_ns: int,
size: int,
) -> Any:
from wasmtime import component
return component.Component.from_file(_wasm_engine(), module_path)
@@ -27,7 +27,7 @@ async def dispatch_wasm_invoice_paid(payment: Any) -> None:
return
try:
from lnbits.core.extensions.wasm import invoke_wasm_extension_export
from lnbits.core.wasm_ext.wasm.invoke import invoke_wasm_extension_export
await invoke_wasm_extension_export(
extension.id,
@@ -58,7 +58,7 @@ async def _wasm_invoice_paid_owner_id(extension: Any, payment: Any) -> str | Non
if not source_id or not source_table:
return None
from lnbits.core.extensions.storage import storage_get_row_owner_id
from lnbits.core.wasm_ext.storage.crud import storage_get_row_owner_id
return await storage_get_row_owner_id(extension.id, source_table, source_id)
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
import asyncio
import re
from collections.abc import Mapping
from typing import Any
from ..api.host import list_extension_api_methods
from ..api.runtime import ExtensionAPIHost
def add_extension_host_imports(
linker: Any,
api_host: ExtensionAPIHost,
event_loop: asyncio.AbstractEventLoop,
) -> None:
with linker.root() as root:
methods_by_interface: dict[str, list[Any]] = {}
for method in list_extension_api_methods():
methods_by_interface.setdefault(method.host_interface, []).append(method)
for host_interface, methods in methods_by_interface.items():
with root.add_instance(f"lnbits:extension/{host_interface}") as host:
for method in methods:
host.add_func(
method.host_name.replace("_", "-"),
_make_host_import(api_host, method.method_id, event_loop),
)
def _make_host_import(
api_host: ExtensionAPIHost,
host_name: str,
event_loop: asyncio.AbstractEventLoop,
) -> Any:
def host_import(_store: Any, request: Any = None) -> Any:
payload = _component_payload_to_dict(request)
future = asyncio.run_coroutine_threadsafe(
api_host.invoke(host_name, payload), event_loop
)
response = future.result()
return _dict_to_component_record(response)
return host_import
def _component_payload_to_dict(value: Any) -> dict[str, Any]:
if value is None:
return {}
if hasattr(value, "__dict__"):
return dict(value.__dict__)
if isinstance(value, Mapping):
return dict(value)
raise TypeError("WASM host function payload must be a record.")
def _dict_to_component_record(value: Mapping[str, Any]) -> Any:
from wasmtime import component
record = component.Record()
for key, item in value.items():
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 _camel_to_kebab(value: str) -> str:
return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value).replace("_", "-").lower()
@@ -2,17 +2,17 @@ from __future__ import annotations
import asyncio
import json
import re
from collections.abc import Mapping
from functools import lru_cache
from typing import Any
from lnbits.core.crud.extensions import get_installed_extension
from lnbits.core.db import core_app_extra
from .api import ExtensionAPI, list_extension_api_methods
from ..api.host import ExtensionHostAPI
from ..api.runtime import ExtensionAPIHost
from .component import _wasm_component, _wasm_engine
from .host import add_extension_host_imports
from .loader import WasmExtension
from .runtime import ExtensionAPIHost
async def invoke_wasm_extension_export(
@@ -27,7 +27,7 @@ async def invoke_wasm_extension_export(
) -> dict[str, Any]:
extension = _get_registered_extension(ext_id)
permissions = await _extension_permissions(extension)
api = ExtensionAPI(
api = ExtensionHostAPI(
extension.id,
permissions,
user_id=_user_id(user),
@@ -47,15 +47,11 @@ async def invoke_wasm_extension_export(
)
def warm_wasm_extension(extension: WasmExtension) -> None:
_wasm_component(extension)
def _invoke_wasm_extension_export_sync(
extension: WasmExtension,
export_name: str,
payload: Mapping[str, Any],
api: ExtensionAPI,
api: ExtensionHostAPI,
event_loop: asyncio.AbstractEventLoop,
) -> dict[str, Any]:
try:
@@ -72,7 +68,7 @@ def _invoke_wasm_extension_export_sync(
linker = component.Linker(engine)
linker.add_wasip2()
_add_extension_host_imports(linker, ExtensionAPIHost(api), event_loop)
add_extension_host_imports(linker, ExtensionAPIHost(api), event_loop)
wasm_component = _wasm_component(extension)
instance = linker.instantiate(store, wasm_component)
@@ -87,103 +83,6 @@ def _invoke_wasm_extension_export_sync(
return _parse_wasm_export_result(extension, result)
@lru_cache(maxsize=1)
def _wasm_engine() -> Any:
try:
from wasmtime import Config, Engine
except ImportError as exc:
raise RuntimeError(
"WASM extension runtime is not installed. Install the 'wasmtime' "
"Python package to run WASM extensions."
) from exc
config = Config()
config.wasm_component_model = True
return Engine(config)
def _wasm_component(extension: WasmExtension) -> Any:
stat = extension.module_path.stat()
return _cached_wasm_component(
str(extension.module_path),
stat.st_mtime_ns,
stat.st_size,
)
@lru_cache(maxsize=32)
def _cached_wasm_component(
module_path: str,
mtime_ns: int,
size: int,
) -> Any:
from wasmtime import component
return component.Component.from_file(_wasm_engine(), module_path)
def _add_extension_host_imports(
linker: Any,
api_host: ExtensionAPIHost,
event_loop: asyncio.AbstractEventLoop,
) -> None:
with linker.root() as root:
methods_by_interface: dict[str, list[Any]] = {}
for method in list_extension_api_methods():
methods_by_interface.setdefault(method.host_interface, []).append(method)
for host_interface, methods in methods_by_interface.items():
with root.add_instance(f"lnbits:extension/{host_interface}") as host:
for method in methods:
host.add_func(
method.host_name.replace("_", "-"),
_make_host_import(api_host, method.method_id, event_loop),
)
def _make_host_import(
api_host: ExtensionAPIHost,
host_name: str,
event_loop: asyncio.AbstractEventLoop,
) -> Any:
def host_import(_store: Any, request: Any = None) -> Any:
payload = _component_payload_to_dict(request)
future = asyncio.run_coroutine_threadsafe(
api_host.invoke(host_name, payload), event_loop
)
response = future.result()
return _dict_to_component_record(response)
return host_import
def _component_payload_to_dict(value: Any) -> dict[str, Any]:
if value is None:
return {}
if hasattr(value, "__dict__"):
return dict(value.__dict__)
if isinstance(value, Mapping):
return dict(value)
raise TypeError("WASM host function payload must be a record.")
def _dict_to_component_record(value: Mapping[str, Any]) -> Any:
from wasmtime import component
record = component.Record()
for key, item in value.items():
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()
@@ -224,7 +123,3 @@ async def _extension_permissions(extension: WasmExtension) -> list[Any]:
def _user_id(user: Any | None) -> str | None:
return getattr(user, "id", None) if user else None
def _camel_to_kebab(value: str) -> str:
return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value).replace("_", "-").lower()
@@ -60,7 +60,7 @@ def load_wasm_extension(ext_id: str) -> WasmExtension:
module_path=module_path,
wit_path=wit_path,
world=wasm_config.get("world") or "",
host_api=wasm_config.get("host_api") or "lnbits.core.extensions.ExtensionAPI",
host_api=wasm_config.get("host_api") or "lnbits.core.wasm_ext.ExtensionHostAPI",
exports=wasm_config.get("exports") or [],
config=config,
)
+9 -9
View File
@@ -10,24 +10,24 @@ from typing import Any, Literal, Union, get_args, get_origin
from pydantic import BaseModel
from lnbits.core.extensions import (
ExtensionAPI,
from lnbits.core.wasm_ext import (
ExtensionAPIMethod,
ExtensionHostAPI,
get_extension_api_method,
list_extension_api_methods,
)
def generate_typescript_sdk(
api_cls: type[ExtensionAPI] | None = None,
api_cls: type[ExtensionHostAPI] | None = None,
method_ids: Sequence[str] | None = None,
) -> str:
api_cls = api_cls or ExtensionAPI
api_cls = api_cls or ExtensionHostAPI
methods = _select_methods(api_cls, method_ids)
models = _collect_models(methods)
lines = [
"/* Generated by LNbits ExtensionAPI codegen. */",
"/* Generated by LNbits ExtensionHostAPI codegen. */",
"/* Do not edit by hand. */",
"",
"export type MaybePromise<T> = T | Promise<T>",
@@ -52,7 +52,7 @@ def generate_typescript_sdk(
def write_typescript_sdk(
path: str | Path,
api_cls: type[ExtensionAPI] | None = None,
api_cls: type[ExtensionHostAPI] | None = None,
method_ids: Sequence[str] | None = None,
) -> None:
Path(path).write_text(
@@ -61,7 +61,7 @@ def write_typescript_sdk(
def _select_methods(
api_cls: type[ExtensionAPI], method_ids: Sequence[str] | None
api_cls: type[ExtensionHostAPI], method_ids: Sequence[str] | None
) -> list[ExtensionAPIMethod]:
if not method_ids:
return list_extension_api_methods(api_cls)
@@ -342,13 +342,13 @@ def _is_empty_model(model: type[BaseModel]) -> bool:
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Generate a TypeScript SDK from the LNbits ExtensionAPI."
description="Generate a TypeScript SDK from the LNbits ExtensionHostAPI."
)
parser.add_argument(
"--method",
action="append",
dest="method_ids",
help="ExtensionAPI method id to include. Can be passed multiple times.",
help="ExtensionHostAPI method id to include. Can be passed multiple times.",
)
parser.add_argument(
"--out",