refactor: components
This commit is contained in:
@@ -140,6 +140,8 @@ def _mount_wasm_extension_static(app: FastAPI, extension: WasmExtension) -> None
|
|||||||
|
|
||||||
|
|
||||||
def _register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -> None:
|
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 []):
|
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_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
|
||||||
entrypoint = _wasm_extension_entrypoint(
|
entrypoint = _wasm_extension_entrypoint(
|
||||||
@@ -147,15 +149,12 @@ def _register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -
|
|||||||
)
|
)
|
||||||
frame_path = f"/ext-frame/{extension.id}/{route_index}"
|
frame_path = f"/ext-frame/{extension.id}/{route_index}"
|
||||||
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
|
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
|
||||||
path_params = route_config.get("path_params") or {}
|
|
||||||
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
|
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
|
||||||
_add_wasm_extension_wrapper_route(
|
_add_wasm_extension_wrapper_route(
|
||||||
app,
|
app,
|
||||||
extension,
|
extension,
|
||||||
route_path,
|
route_path,
|
||||||
frame_path,
|
|
||||||
auth,
|
auth,
|
||||||
path_params,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -222,6 +221,52 @@ def _add_wasm_extension_api_route(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_wasm_extension_frame_config_route(
|
||||||
|
app: FastAPI,
|
||||||
|
extension: WasmExtension,
|
||||||
|
) -> None:
|
||||||
|
route_path = _wasm_extension_frame_config_path(extension)
|
||||||
|
if _has_route(app, route_path, "POST"):
|
||||||
|
return
|
||||||
|
|
||||||
|
async def create_wasm_extension_frame_config(
|
||||||
|
request: Request,
|
||||||
|
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||||
|
usr: UUID4 | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
body = await _read_json_object(request)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
ui_route = _match_wasm_extension_ui_route(extension, body.get("path"))
|
||||||
|
auth = ui_route["auth"]
|
||||||
|
|
||||||
|
if auth == "user":
|
||||||
|
account = await check_account_exists(request, access_token, usr)
|
||||||
|
user_id: str | None = account.id
|
||||||
|
else:
|
||||||
|
user_id = await _optional_wasm_user_id(request, access_token, usr)
|
||||||
|
|
||||||
|
return _wasm_extension_frame_config(
|
||||||
|
extension,
|
||||||
|
ui_route["frame_path"],
|
||||||
|
auth,
|
||||||
|
ui_route["path_params"],
|
||||||
|
ui_route["route_params"],
|
||||||
|
_read_wasm_extension_route_query(body.get("query")),
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_api_route(
|
||||||
|
route_path,
|
||||||
|
create_wasm_extension_frame_config,
|
||||||
|
methods=["POST"],
|
||||||
|
name=f"{extension.id}:frame-config",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _read_api_payload(
|
async def _read_api_payload(
|
||||||
request: Request,
|
request: Request,
|
||||||
path_params: dict[str, str],
|
path_params: dict[str, str],
|
||||||
@@ -307,9 +352,7 @@ def _add_wasm_extension_wrapper_route(
|
|||||||
app: FastAPI,
|
app: FastAPI,
|
||||||
extension: WasmExtension,
|
extension: WasmExtension,
|
||||||
route_path: str,
|
route_path: str,
|
||||||
frame_path: str,
|
|
||||||
auth: str,
|
auth: str,
|
||||||
path_params: dict[str, str],
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if _has_route(app, route_path, "GET"):
|
if _has_route(app, route_path, "GET"):
|
||||||
return
|
return
|
||||||
@@ -322,25 +365,16 @@ def _add_wasm_extension_wrapper_route(
|
|||||||
return _wasm_extension_wrapper_response(
|
return _wasm_extension_wrapper_response(
|
||||||
request,
|
request,
|
||||||
extension,
|
extension,
|
||||||
frame_path,
|
|
||||||
auth,
|
auth,
|
||||||
path_params,
|
|
||||||
user.json() if user else None,
|
user.json() if user else None,
|
||||||
account.id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def serve_public_wasm_extension_page(
|
async def serve_public_wasm_extension_page(request: Request) -> Any:
|
||||||
request: Request,
|
|
||||||
user_id: str | None = Depends(_optional_wasm_user_id),
|
|
||||||
) -> Any:
|
|
||||||
return _wasm_extension_wrapper_response(
|
return _wasm_extension_wrapper_response(
|
||||||
request,
|
request,
|
||||||
extension,
|
extension,
|
||||||
frame_path,
|
|
||||||
auth,
|
auth,
|
||||||
path_params,
|
|
||||||
None,
|
None,
|
||||||
user_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_api_route(
|
app.add_api_route(
|
||||||
@@ -398,11 +432,8 @@ def _add_wasm_extension_frame_route(
|
|||||||
def _wasm_extension_wrapper_response(
|
def _wasm_extension_wrapper_response(
|
||||||
request: Request,
|
request: Request,
|
||||||
extension: WasmExtension,
|
extension: WasmExtension,
|
||||||
frame_path: str,
|
|
||||||
auth: str,
|
auth: str,
|
||||||
path_params: dict[str, str],
|
|
||||||
user_json: str | None,
|
user_json: str | None,
|
||||||
user_id: str | None,
|
|
||||||
) -> Any:
|
) -> Any:
|
||||||
public = auth == "public"
|
public = auth == "public"
|
||||||
response = template_renderer().TemplateResponse(
|
response = template_renderer().TemplateResponse(
|
||||||
@@ -410,14 +441,6 @@ def _wasm_extension_wrapper_response(
|
|||||||
"wasm_extension.html",
|
"wasm_extension.html",
|
||||||
{
|
{
|
||||||
"extension": extension,
|
"extension": extension,
|
||||||
"frame_url": _wasm_extension_frame_url(extension, frame_path, user_id),
|
|
||||||
"bridge": {
|
|
||||||
"extensionId": extension.id,
|
|
||||||
"public": public,
|
|
||||||
"routeParams": _read_api_path_params(request, path_params),
|
|
||||||
"query": _read_api_query_params(request),
|
|
||||||
"apiRoutes": _wasm_extension_bridge_api_routes(extension, public),
|
|
||||||
},
|
|
||||||
"public": public,
|
"public": public,
|
||||||
"user": user_json,
|
"user": user_json,
|
||||||
},
|
},
|
||||||
@@ -541,6 +564,107 @@ def _wasm_extension_bridge_api_routes(
|
|||||||
return routes
|
return routes
|
||||||
|
|
||||||
|
|
||||||
|
def _wasm_extension_frame_config_path(extension: WasmExtension) -> str:
|
||||||
|
return f"/api/v1/ext/{extension.id}/_ui/frame"
|
||||||
|
|
||||||
|
|
||||||
|
def _match_wasm_extension_ui_route(
|
||||||
|
extension: WasmExtension,
|
||||||
|
path: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not isinstance(path, str) or not path.startswith("/"):
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
|
||||||
|
route_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
|
||||||
|
route_params = _path_template_params(route_path, path)
|
||||||
|
if route_params is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return {
|
||||||
|
"frame_path": f"/ext-frame/{extension.id}/{route_index}",
|
||||||
|
"auth": _wasm_extension_route_auth(extension, route_config.get("auth")),
|
||||||
|
"path_params": route_config.get("path_params") or {},
|
||||||
|
"route_params": route_params,
|
||||||
|
}
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _path_template_params(template: str, path: str) -> dict[str, str] | None:
|
||||||
|
template_parts = _path_parts(template)
|
||||||
|
path_parts = _path_parts(path)
|
||||||
|
if len(template_parts) != len(path_parts):
|
||||||
|
return None
|
||||||
|
|
||||||
|
params: dict[str, str] = {}
|
||||||
|
for template_part, path_part in zip(template_parts, path_parts, strict=False):
|
||||||
|
if template_part.startswith("{") and template_part.endswith("}"):
|
||||||
|
param_name = template_part[1:-1]
|
||||||
|
if not param_name:
|
||||||
|
return None
|
||||||
|
params[param_name] = path_part
|
||||||
|
continue
|
||||||
|
|
||||||
|
if template_part != path_part:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def _path_parts(path: str) -> list[str]:
|
||||||
|
return [part for part in path.strip("/").split("/") if part]
|
||||||
|
|
||||||
|
|
||||||
|
def _wasm_extension_frame_config(
|
||||||
|
extension: WasmExtension,
|
||||||
|
frame_path: str,
|
||||||
|
auth: str,
|
||||||
|
path_params: dict[str, str],
|
||||||
|
route_params: dict[str, str],
|
||||||
|
query: dict[str, Any],
|
||||||
|
user_id: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
public = auth == "public"
|
||||||
|
return {
|
||||||
|
"extension": {
|
||||||
|
"id": extension.id,
|
||||||
|
"name": extension.name,
|
||||||
|
},
|
||||||
|
"frameUrl": _wasm_extension_frame_url(extension, frame_path, user_id),
|
||||||
|
"bridge": {
|
||||||
|
"extensionId": extension.id,
|
||||||
|
"public": public,
|
||||||
|
"routeParams": _map_wasm_extension_route_params(route_params, path_params),
|
||||||
|
"query": query,
|
||||||
|
"apiRoutes": _wasm_extension_bridge_api_routes(extension, public),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _map_wasm_extension_route_params(
|
||||||
|
route_params: dict[str, str],
|
||||||
|
path_params: dict[str, str],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
payload: dict[str, str] = {}
|
||||||
|
for key, value in route_params.items():
|
||||||
|
target = path_params.get(key) or _snake_to_camel(key)
|
||||||
|
payload[target] = value
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _read_wasm_extension_route_query(query: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(query, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
for key, value in query.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
payload[_snake_to_camel(str(key))] = value
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _path_template_pattern(path: str) -> str:
|
def _path_template_pattern(path: str) -> str:
|
||||||
pattern = re.sub(r"\\{[^/{}]+\\}", r"[^/]+", re.escape(path))
|
pattern = re.sub(r"\\{[^/{}]+\\}", r"[^/]+", re.escape(path))
|
||||||
return f"^{pattern}$"
|
return f"^{pattern}$"
|
||||||
|
|||||||
@@ -139,6 +139,16 @@ const routes = [
|
|||||||
name: 'PageError',
|
name: 'PageError',
|
||||||
component: PageError
|
component: PageError
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/ext/:extId',
|
||||||
|
name: 'WasmExtensionRoot',
|
||||||
|
component: window.WasmExtensionComponent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/ext/:extId/:pathMatch(.*)*',
|
||||||
|
name: 'WasmExtension',
|
||||||
|
component: window.WasmExtensionComponent
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
name: 'DynamicComponent',
|
name: 'DynamicComponent',
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
window.WasmExtensionComponent = {
|
||||||
|
template: `
|
||||||
|
<div class="wasm-extension-page relative-position">
|
||||||
|
<q-inner-loading :showing="loading && !frameUrl">
|
||||||
|
<q-spinner-dots size="40px"></q-spinner-dots>
|
||||||
|
</q-inner-loading>
|
||||||
|
<q-banner v-if="error" class="q-ma-md bg-negative text-white">
|
||||||
|
{{ error }}
|
||||||
|
</q-banner>
|
||||||
|
<iframe
|
||||||
|
v-else-if="frameUrl"
|
||||||
|
ref="frame"
|
||||||
|
:key="frameUrl"
|
||||||
|
class="wasm-extension-frame"
|
||||||
|
:src="frameUrl"
|
||||||
|
:title="extensionName || 'Extension'"
|
||||||
|
sandbox="allow-scripts"
|
||||||
|
allow="clipboard-write"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
allowedPaymentHashes: new Set(),
|
||||||
|
bridge: {
|
||||||
|
apiRoutes: [],
|
||||||
|
extensionId: '',
|
||||||
|
public: false,
|
||||||
|
query: {},
|
||||||
|
routeParams: {}
|
||||||
|
},
|
||||||
|
bridgePort: null,
|
||||||
|
error: '',
|
||||||
|
extensionName: '',
|
||||||
|
frameUrl: '',
|
||||||
|
handleWindowMessage: null,
|
||||||
|
loading: false,
|
||||||
|
loadId: 0,
|
||||||
|
paymentSubscriptions: new Map()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.handleWindowMessage = event => this.onWindowMessage(event)
|
||||||
|
window.addEventListener('message', this.handleWindowMessage)
|
||||||
|
},
|
||||||
|
unmounted() {
|
||||||
|
window.removeEventListener('message', this.handleWindowMessage)
|
||||||
|
this.closeBridgePort()
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'$route.fullPath': {
|
||||||
|
immediate: true,
|
||||||
|
handler() {
|
||||||
|
this.loadFrameConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
emptyBridge() {
|
||||||
|
return {
|
||||||
|
apiRoutes: [],
|
||||||
|
extensionId: '',
|
||||||
|
public: false,
|
||||||
|
query: {},
|
||||||
|
routeParams: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plainBridgeContext() {
|
||||||
|
return {
|
||||||
|
extensionId: String(this.bridge.extensionId || ''),
|
||||||
|
public: Boolean(this.bridge.public),
|
||||||
|
routeParams: this.plainValue(this.bridge.routeParams || {}),
|
||||||
|
query: this.plainValue(this.bridge.query || {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plainValue(value) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(value))
|
||||||
|
} catch (_error) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadFrameConfig() {
|
||||||
|
const extId = String(this.$route.params.extId || '')
|
||||||
|
const loadId = ++this.loadId
|
||||||
|
this.loading = true
|
||||||
|
this.error = ''
|
||||||
|
this.frameUrl = ''
|
||||||
|
this.bridge = this.emptyBridge()
|
||||||
|
this.allowedPaymentHashes.clear()
|
||||||
|
this.closeBridgePort()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/ext/${encodeURIComponent(extId)}/_ui/frame`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: this.$route.path,
|
||||||
|
query: this.$route.query || {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const text = await response.text()
|
||||||
|
let data = {}
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text)
|
||||||
|
} catch (_error) {
|
||||||
|
data = {detail: text}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.detail || 'Failed to load extension page.')
|
||||||
|
}
|
||||||
|
if (loadId !== this.loadId) return
|
||||||
|
|
||||||
|
this.bridge = data.bridge || this.emptyBridge()
|
||||||
|
this.extensionName = data.extension?.name || extId
|
||||||
|
this.frameUrl = data.frameUrl
|
||||||
|
} catch (error) {
|
||||||
|
if (loadId !== this.loadId) return
|
||||||
|
console.error('[lnbits wasm extension] Failed to load frame.', error)
|
||||||
|
this.error = error instanceof Error ? error.message : String(error)
|
||||||
|
} finally {
|
||||||
|
if (loadId === this.loadId) {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extensionFrameWindow() {
|
||||||
|
return this.$refs.frame?.contentWindow
|
||||||
|
},
|
||||||
|
sendResponse(reply, id, payload) {
|
||||||
|
reply({
|
||||||
|
type: 'lnbits-extension:response',
|
||||||
|
id,
|
||||||
|
...payload
|
||||||
|
})
|
||||||
|
},
|
||||||
|
allowedApiRoute(method, path) {
|
||||||
|
let url
|
||||||
|
try {
|
||||||
|
url = new URL(path, window.location.origin)
|
||||||
|
} catch (_error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (url.origin !== window.location.origin) return false
|
||||||
|
|
||||||
|
method = String(method || 'GET').toUpperCase()
|
||||||
|
return (this.bridge.apiRoutes || []).some(route => {
|
||||||
|
return (
|
||||||
|
route.method === method &&
|
||||||
|
new RegExp(route.pattern).test(url.pathname)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async callApi(message) {
|
||||||
|
const method = String(message.method || 'GET').toUpperCase()
|
||||||
|
const path = String(message.path || '')
|
||||||
|
if (!this.allowedApiRoute(method, path)) {
|
||||||
|
throw new Error('Extension API route is not allowed.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: {},
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}
|
||||||
|
if (message.body !== undefined && message.body !== null) {
|
||||||
|
options.headers['content-type'] = 'application/json'
|
||||||
|
options.body = JSON.stringify(message.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(path, options)
|
||||||
|
const text = await response.text()
|
||||||
|
let data = text
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text)
|
||||||
|
} catch (_error) {
|
||||||
|
data = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
typeof data === 'object' && data.detail ? data.detail : text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.rememberPaymentHashes(data)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
notify(message) {
|
||||||
|
const level = ['positive', 'negative', 'warning', 'info'].includes(
|
||||||
|
message.level
|
||||||
|
)
|
||||||
|
? message.level
|
||||||
|
: 'info'
|
||||||
|
if (window.Quasar?.Notify) {
|
||||||
|
window.Quasar.Notify.create({
|
||||||
|
color: level,
|
||||||
|
message: String(message.message || '')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rememberPaymentHashes(value) {
|
||||||
|
if (!value || typeof value !== 'object') return
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach(item => this.rememberPaymentHashes(item))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, item] of Object.entries(value)) {
|
||||||
|
if (
|
||||||
|
['paymentHash', 'payment_hash'].includes(key) &&
|
||||||
|
this.isPaymentHash(item)
|
||||||
|
) {
|
||||||
|
this.allowedPaymentHashes.add(item)
|
||||||
|
}
|
||||||
|
this.rememberPaymentHashes(item)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isPaymentHash(value) {
|
||||||
|
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)
|
||||||
|
},
|
||||||
|
websocketUrl(path) {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
url.pathname = path
|
||||||
|
url.search = ''
|
||||||
|
url.hash = ''
|
||||||
|
return url.toString()
|
||||||
|
},
|
||||||
|
sendBridgeEvent(message) {
|
||||||
|
if (!this.bridgePort) return
|
||||||
|
this.bridgePort.postMessage({
|
||||||
|
type: 'lnbits-extension:event',
|
||||||
|
...message
|
||||||
|
})
|
||||||
|
},
|
||||||
|
closePaymentSubscription(subscriptionId) {
|
||||||
|
const subscription = this.paymentSubscriptions.get(subscriptionId)
|
||||||
|
if (!subscription) return
|
||||||
|
this.paymentSubscriptions.delete(subscriptionId)
|
||||||
|
try {
|
||||||
|
subscription.socket.close()
|
||||||
|
} catch (_error) {}
|
||||||
|
},
|
||||||
|
closePaymentSubscriptions() {
|
||||||
|
for (const subscriptionId of Array.from(
|
||||||
|
this.paymentSubscriptions.keys()
|
||||||
|
)) {
|
||||||
|
this.closePaymentSubscription(subscriptionId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
closeBridgePort() {
|
||||||
|
this.closePaymentSubscriptions()
|
||||||
|
this.bridgePort?.close()
|
||||||
|
this.bridgePort = null
|
||||||
|
},
|
||||||
|
subscribePayment(message) {
|
||||||
|
const subscriptionId = String(message.subscriptionId || '')
|
||||||
|
const paymentHash = String(message.paymentHash || '')
|
||||||
|
|
||||||
|
if (!subscriptionId || !this.isPaymentHash(paymentHash)) {
|
||||||
|
throw new Error('Invalid payment subscription.')
|
||||||
|
}
|
||||||
|
if (!this.allowedPaymentHashes.has(paymentHash)) {
|
||||||
|
throw new Error('Payment subscription is not allowed.')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closePaymentSubscription(subscriptionId)
|
||||||
|
|
||||||
|
const socket = new WebSocket(
|
||||||
|
this.websocketUrl(`/api/v1/ws/${encodeURIComponent(paymentHash)}`)
|
||||||
|
)
|
||||||
|
this.paymentSubscriptions.set(subscriptionId, {paymentHash, socket})
|
||||||
|
|
||||||
|
socket.addEventListener('message', event => {
|
||||||
|
let data = event.data
|
||||||
|
try {
|
||||||
|
data = JSON.parse(event.data)
|
||||||
|
} catch (_error) {}
|
||||||
|
|
||||||
|
this.sendBridgeEvent({
|
||||||
|
event: 'payment.update',
|
||||||
|
subscriptionId,
|
||||||
|
paymentHash,
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
if (
|
||||||
|
data &&
|
||||||
|
typeof data === 'object' &&
|
||||||
|
(data.pending === false ||
|
||||||
|
['success', 'settled', 'paid'].includes(String(data.status || '')))
|
||||||
|
) {
|
||||||
|
this.sendBridgeEvent({
|
||||||
|
event: 'payment.settled',
|
||||||
|
subscriptionId,
|
||||||
|
paymentHash,
|
||||||
|
data
|
||||||
|
})
|
||||||
|
this.closePaymentSubscription(subscriptionId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
socket.addEventListener('error', () => {
|
||||||
|
this.sendBridgeEvent({
|
||||||
|
event: 'payment.error',
|
||||||
|
subscriptionId,
|
||||||
|
paymentHash
|
||||||
|
})
|
||||||
|
this.closePaymentSubscription(subscriptionId)
|
||||||
|
})
|
||||||
|
socket.addEventListener('close', () => {
|
||||||
|
this.paymentSubscriptions.delete(subscriptionId)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async handleBridgeRequest(message, reply) {
|
||||||
|
if (!message || message.type !== 'lnbits-extension:request') return
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (message.action === 'context') {
|
||||||
|
this.sendResponse(reply, message.id, {
|
||||||
|
ok: true,
|
||||||
|
data: this.plainBridgeContext()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.action === 'api') {
|
||||||
|
this.sendResponse(reply, message.id, {
|
||||||
|
ok: true,
|
||||||
|
data: await this.callApi(message)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.action === 'ui.notify') {
|
||||||
|
this.notify(message)
|
||||||
|
this.sendResponse(reply, message.id, {
|
||||||
|
ok: true,
|
||||||
|
data: {ok: true}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.action === 'payment.subscribe') {
|
||||||
|
this.subscribePayment(message)
|
||||||
|
this.sendResponse(reply, message.id, {
|
||||||
|
ok: true,
|
||||||
|
data: {ok: true}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.action === 'payment.unsubscribe') {
|
||||||
|
this.closePaymentSubscription(String(message.subscriptionId || ''))
|
||||||
|
this.sendResponse(reply, message.id, {
|
||||||
|
ok: true,
|
||||||
|
data: {ok: true}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unknown extension bridge action.')
|
||||||
|
} catch (error) {
|
||||||
|
this.sendResponse(reply, message.id, {
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onWindowMessage(event) {
|
||||||
|
if (event.source !== this.extensionFrameWindow()) return
|
||||||
|
const message = event.data
|
||||||
|
if (!message || message.type !== 'lnbits-extension:connect') return
|
||||||
|
|
||||||
|
const port = event.ports?.[0]
|
||||||
|
if (!port) return
|
||||||
|
|
||||||
|
this.closeBridgePort()
|
||||||
|
this.bridgePort = port
|
||||||
|
this.bridgePort.addEventListener('message', portEvent => {
|
||||||
|
this.handleBridgeRequest(portEvent.data, response => {
|
||||||
|
port.postMessage(response)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
this.bridgePort.start()
|
||||||
|
this.bridgePort.postMessage({
|
||||||
|
type: 'lnbits-extension:connected',
|
||||||
|
id: message.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,7 @@
|
|||||||
"js/components/extension-settings.js",
|
"js/components/extension-settings.js",
|
||||||
"js/components/data-fields.js",
|
"js/components/data-fields.js",
|
||||||
"js/components.js",
|
"js/components.js",
|
||||||
|
"js/wasm-extension-component.js",
|
||||||
"js/init-app.js"
|
"js/init-app.js"
|
||||||
],
|
],
|
||||||
"css": ["vendor/quasar.css", "css/base.css"]
|
"css": ["vendor/quasar.css", "css/base.css"]
|
||||||
|
|||||||
@@ -16,6 +16,18 @@
|
|||||||
src: url("{{ static_url_for('static', 'fonts/material-icons-v50.woff2') }}")
|
src: url("{{ static_url_for('static', 'fonts/material-icons-v50.woff2') }}")
|
||||||
format('woff2');
|
format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wasm-extension-page,
|
||||||
|
.wasm-extension-frame {
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
min-height: calc(100vh - 56px);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wasm-extension-frame {
|
||||||
|
border: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<title>{% block title %}{{ SITE_TITLE }}{% endblock %}</title>
|
<title>{% block title %}{{ SITE_TITLE }}{% endblock %}</title>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
@@ -44,12 +56,14 @@
|
|||||||
<lnbits-drawer v-if="g.user && !g.isPublicPage"></lnbits-drawer>
|
<lnbits-drawer v-if="g.user && !g.isPublicPage"></lnbits-drawer>
|
||||||
{% block page_container %}
|
{% block page_container %}
|
||||||
<q-page-container>
|
<q-page-container>
|
||||||
<q-page class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
|
<q-page
|
||||||
|
:class="$route.path.startsWith('/ext/') ? 'q-pa-none' : ['q-px-md', 'q-py-lg', {'q-px-lg': $q.screen.gt.xs}]"
|
||||||
|
>
|
||||||
<lnbits-wallet-new
|
<lnbits-wallet-new
|
||||||
v-if="g.user && !g.isPublicPage"
|
v-if="g.user && !g.isPublicPage && !$route.path.startsWith('/ext/')"
|
||||||
></lnbits-wallet-new>
|
></lnbits-wallet-new>
|
||||||
<lnbits-header-wallets
|
<lnbits-header-wallets
|
||||||
v-if="g.user && !g.isPublicPage"
|
v-if="g.user && !g.isPublicPage && !$route.path.startsWith('/ext/')"
|
||||||
></lnbits-header-wallets>
|
></lnbits-header-wallets>
|
||||||
<!-- block page content from static extensions -->
|
<!-- block page content from static extensions -->
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,351 +1 @@
|
|||||||
{% extends "base.html" %} {% block styles %}
|
{% extends "base.html" %}
|
||||||
<style>
|
|
||||||
.wasm-extension-frame {
|
|
||||||
border: 0;
|
|
||||||
display: block;
|
|
||||||
height: calc(100vh - 56px);
|
|
||||||
min-height: calc(100vh - 56px);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %} {% block page_container %}
|
|
||||||
<q-page-container>
|
|
||||||
<!-- # todo:revisit this -->
|
|
||||||
<q-page
|
|
||||||
v-if="$route.path.startsWith('{{ normalize_path(request.path) }}')"
|
|
||||||
class="q-pa-none"
|
|
||||||
>
|
|
||||||
<iframe
|
|
||||||
id="lnbits-wasm-extension-frame"
|
|
||||||
class="wasm-extension-frame"
|
|
||||||
data-frame-url="{{ frame_url }}"
|
|
||||||
title="{{ extension.name }}"
|
|
||||||
sandbox="allow-scripts"
|
|
||||||
allow="clipboard-write"
|
|
||||||
referrerpolicy="no-referrer"
|
|
||||||
></iframe>
|
|
||||||
</q-page>
|
|
||||||
<q-page v-else class="q-px-md q-py-lg" :class="{'q-px-lg': $q.screen.gt.xs}">
|
|
||||||
<lnbits-wallet-new v-if="g.user && !g.isPublicPage"></lnbits-wallet-new>
|
|
||||||
<lnbits-header-wallets
|
|
||||||
v-if="g.user && !g.isPublicPage"
|
|
||||||
></lnbits-header-wallets>
|
|
||||||
<router-view :key="$route.path"></router-view>
|
|
||||||
</q-page>
|
|
||||||
</q-page-container>
|
|
||||||
{% endblock %} {% block scripts %}
|
|
||||||
<script>
|
|
||||||
;(() => {
|
|
||||||
const bridge = {{ bridge | tojson | safe }}
|
|
||||||
let bridgePort = null
|
|
||||||
const allowedPaymentHashes = new Set()
|
|
||||||
const paymentSubscriptions = new Map()
|
|
||||||
|
|
||||||
function extensionFrameWindow() {
|
|
||||||
return extensionFrame()?.contentWindow
|
|
||||||
}
|
|
||||||
|
|
||||||
function extensionFrame() {
|
|
||||||
return document.getElementById('lnbits-wasm-extension-frame')
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadExtensionFrame() {
|
|
||||||
const frame = extensionFrame()
|
|
||||||
if (!frame) {
|
|
||||||
closeBridgePort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (frame.dataset.loaded === 'true') return
|
|
||||||
frame.dataset.loaded = 'true'
|
|
||||||
frame.src = frame.dataset.frameUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
function startExtensionFrameLoader() {
|
|
||||||
loadExtensionFrame()
|
|
||||||
window.router?.afterEach(() => {
|
|
||||||
window.setTimeout(loadExtensionFrame)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendResponse(reply, id, payload) {
|
|
||||||
reply({
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
rememberPaymentHashes(data)
|
|
||||||
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 || '')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rememberPaymentHashes(value) {
|
|
||||||
if (!value || typeof value !== 'object') return
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
value.forEach(rememberPaymentHashes)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, item] of Object.entries(value)) {
|
|
||||||
if (
|
|
||||||
['paymentHash', 'payment_hash'].includes(key) &&
|
|
||||||
isPaymentHash(item)
|
|
||||||
) {
|
|
||||||
allowedPaymentHashes.add(item)
|
|
||||||
}
|
|
||||||
rememberPaymentHashes(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPaymentHash(value) {
|
|
||||||
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function websocketUrl(path) {
|
|
||||||
const url = new URL(window.location.href)
|
|
||||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
||||||
url.pathname = path
|
|
||||||
url.search = ''
|
|
||||||
url.hash = ''
|
|
||||||
return url.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendBridgeEvent(message) {
|
|
||||||
if (!bridgePort) return
|
|
||||||
bridgePort.postMessage({
|
|
||||||
type: 'lnbits-extension:event',
|
|
||||||
...message
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePaymentSubscription(subscriptionId) {
|
|
||||||
const subscription = paymentSubscriptions.get(subscriptionId)
|
|
||||||
if (!subscription) return
|
|
||||||
paymentSubscriptions.delete(subscriptionId)
|
|
||||||
try {
|
|
||||||
subscription.socket.close()
|
|
||||||
} catch (_error) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePaymentSubscriptions() {
|
|
||||||
for (const subscriptionId of Array.from(paymentSubscriptions.keys())) {
|
|
||||||
closePaymentSubscription(subscriptionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeBridgePort() {
|
|
||||||
closePaymentSubscriptions()
|
|
||||||
bridgePort?.close()
|
|
||||||
bridgePort = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribePayment(message) {
|
|
||||||
const subscriptionId = String(message.subscriptionId || '')
|
|
||||||
const paymentHash = String(message.paymentHash || '')
|
|
||||||
|
|
||||||
if (!subscriptionId || !isPaymentHash(paymentHash)) {
|
|
||||||
throw new Error('Invalid payment subscription.')
|
|
||||||
}
|
|
||||||
if (!allowedPaymentHashes.has(paymentHash)) {
|
|
||||||
throw new Error('Payment subscription is not allowed.')
|
|
||||||
}
|
|
||||||
|
|
||||||
closePaymentSubscription(subscriptionId)
|
|
||||||
|
|
||||||
const socket = new WebSocket(
|
|
||||||
websocketUrl(`/api/v1/ws/${encodeURIComponent(paymentHash)}`)
|
|
||||||
)
|
|
||||||
paymentSubscriptions.set(subscriptionId, {paymentHash, socket})
|
|
||||||
|
|
||||||
socket.addEventListener('message', event => {
|
|
||||||
let data = event.data
|
|
||||||
try {
|
|
||||||
data = JSON.parse(event.data)
|
|
||||||
} catch (_error) {}
|
|
||||||
|
|
||||||
sendBridgeEvent({
|
|
||||||
event: 'payment.update',
|
|
||||||
subscriptionId,
|
|
||||||
paymentHash,
|
|
||||||
data
|
|
||||||
})
|
|
||||||
|
|
||||||
if (
|
|
||||||
data &&
|
|
||||||
typeof data === 'object' &&
|
|
||||||
(data.pending === false ||
|
|
||||||
['success', 'settled', 'paid'].includes(String(data.status || '')))
|
|
||||||
) {
|
|
||||||
sendBridgeEvent({
|
|
||||||
event: 'payment.settled',
|
|
||||||
subscriptionId,
|
|
||||||
paymentHash,
|
|
||||||
data
|
|
||||||
})
|
|
||||||
closePaymentSubscription(subscriptionId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
socket.addEventListener('error', () => {
|
|
||||||
sendBridgeEvent({
|
|
||||||
event: 'payment.error',
|
|
||||||
subscriptionId,
|
|
||||||
paymentHash
|
|
||||||
})
|
|
||||||
closePaymentSubscription(subscriptionId)
|
|
||||||
})
|
|
||||||
socket.addEventListener('close', () => {
|
|
||||||
paymentSubscriptions.delete(subscriptionId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBridgeRequest(message, reply) {
|
|
||||||
if (!message || message.type !== 'lnbits-extension:request') return
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (message.action === 'context') {
|
|
||||||
sendResponse(reply, message.id, {
|
|
||||||
ok: true,
|
|
||||||
data: {
|
|
||||||
extensionId: bridge.extensionId,
|
|
||||||
public: bridge.public,
|
|
||||||
routeParams: bridge.routeParams,
|
|
||||||
query: bridge.query
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.action === 'api') {
|
|
||||||
sendResponse(reply, message.id, {
|
|
||||||
ok: true,
|
|
||||||
data: await callApi(message)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.action === 'ui.notify') {
|
|
||||||
notify(message)
|
|
||||||
sendResponse(reply, message.id, {
|
|
||||||
ok: true,
|
|
||||||
data: {ok: true}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.action === 'payment.subscribe') {
|
|
||||||
subscribePayment(message)
|
|
||||||
sendResponse(reply, message.id, {
|
|
||||||
ok: true,
|
|
||||||
data: {ok: true}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.action === 'payment.unsubscribe') {
|
|
||||||
closePaymentSubscription(String(message.subscriptionId || ''))
|
|
||||||
sendResponse(reply, message.id, {
|
|
||||||
ok: true,
|
|
||||||
data: {ok: true}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Unknown extension bridge action.')
|
|
||||||
} catch (error) {
|
|
||||||
sendResponse(reply, message.id, {
|
|
||||||
ok: false,
|
|
||||||
error: error instanceof Error ? error.message : String(error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('message', event => {
|
|
||||||
if (event.source !== extensionFrameWindow()) return
|
|
||||||
const message = event.data
|
|
||||||
if (!message || message.type !== 'lnbits-extension:connect') return
|
|
||||||
|
|
||||||
const port = event.ports?.[0]
|
|
||||||
if (!port) return
|
|
||||||
|
|
||||||
closeBridgePort()
|
|
||||||
bridgePort = port
|
|
||||||
bridgePort.addEventListener('message', portEvent => {
|
|
||||||
handleBridgeRequest(portEvent.data, response => {
|
|
||||||
port.postMessage(response)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
bridgePort.start()
|
|
||||||
bridgePort.postMessage({
|
|
||||||
type: 'lnbits-extension:connected',
|
|
||||||
id: message.id
|
|
||||||
})
|
|
||||||
})
|
|
||||||
window.addEventListener('load', startExtensionFrameLoader)
|
|
||||||
})()
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@@ -150,6 +150,7 @@
|
|||||||
"js/components/extension-settings.js",
|
"js/components/extension-settings.js",
|
||||||
"js/components/data-fields.js",
|
"js/components/data-fields.js",
|
||||||
"js/components.js",
|
"js/components.js",
|
||||||
|
"js/wasm-extension-component.js",
|
||||||
"js/init-app.js"
|
"js/init-app.js"
|
||||||
],
|
],
|
||||||
"css": [
|
"css": [
|
||||||
|
|||||||
Reference in New Issue
Block a user