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:
|
||||
_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(
|
||||
@@ -147,15 +149,12 @@ def _register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -
|
||||
)
|
||||
frame_path = f"/ext-frame/{extension.id}/{route_index}"
|
||||
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
|
||||
path_params = route_config.get("path_params") or {}
|
||||
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
|
||||
_add_wasm_extension_wrapper_route(
|
||||
app,
|
||||
extension,
|
||||
route_path,
|
||||
frame_path,
|
||||
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(
|
||||
request: Request,
|
||||
path_params: dict[str, str],
|
||||
@@ -307,9 +352,7 @@ def _add_wasm_extension_wrapper_route(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
route_path: str,
|
||||
frame_path: str,
|
||||
auth: str,
|
||||
path_params: dict[str, str],
|
||||
) -> None:
|
||||
if _has_route(app, route_path, "GET"):
|
||||
return
|
||||
@@ -322,25 +365,16 @@ def _add_wasm_extension_wrapper_route(
|
||||
return _wasm_extension_wrapper_response(
|
||||
request,
|
||||
extension,
|
||||
frame_path,
|
||||
auth,
|
||||
path_params,
|
||||
user.json() if user else None,
|
||||
account.id,
|
||||
)
|
||||
|
||||
async def serve_public_wasm_extension_page(
|
||||
request: Request,
|
||||
user_id: str | None = Depends(_optional_wasm_user_id),
|
||||
) -> Any:
|
||||
async def serve_public_wasm_extension_page(request: Request) -> Any:
|
||||
return _wasm_extension_wrapper_response(
|
||||
request,
|
||||
extension,
|
||||
frame_path,
|
||||
auth,
|
||||
path_params,
|
||||
None,
|
||||
user_id,
|
||||
)
|
||||
|
||||
app.add_api_route(
|
||||
@@ -398,11 +432,8 @@ def _add_wasm_extension_frame_route(
|
||||
def _wasm_extension_wrapper_response(
|
||||
request: Request,
|
||||
extension: WasmExtension,
|
||||
frame_path: str,
|
||||
auth: str,
|
||||
path_params: dict[str, str],
|
||||
user_json: str | None,
|
||||
user_id: str | None,
|
||||
) -> Any:
|
||||
public = auth == "public"
|
||||
response = template_renderer().TemplateResponse(
|
||||
@@ -410,14 +441,6 @@ def _wasm_extension_wrapper_response(
|
||||
"wasm_extension.html",
|
||||
{
|
||||
"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,
|
||||
"user": user_json,
|
||||
},
|
||||
@@ -541,6 +564,107 @@ def _wasm_extension_bridge_api_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:
|
||||
pattern = re.sub(r"\\{[^/{}]+\\}", r"[^/]+", re.escape(path))
|
||||
return f"^{pattern}$"
|
||||
|
||||
@@ -139,6 +139,16 @@ const routes = [
|
||||
name: 'PageError',
|
||||
component: PageError
|
||||
},
|
||||
{
|
||||
path: '/ext/:extId',
|
||||
name: 'WasmExtensionRoot',
|
||||
component: window.WasmExtensionComponent
|
||||
},
|
||||
{
|
||||
path: '/ext/:extId/:pathMatch(.*)*',
|
||||
name: 'WasmExtension',
|
||||
component: window.WasmExtensionComponent
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,7 @@
|
||||
"js/components/extension-settings.js",
|
||||
"js/components/data-fields.js",
|
||||
"js/components.js",
|
||||
"js/wasm-extension-component.js",
|
||||
"js/init-app.js"
|
||||
],
|
||||
"css": ["vendor/quasar.css", "css/base.css"]
|
||||
|
||||
@@ -16,6 +16,18 @@
|
||||
src: url("{{ static_url_for('static', 'fonts/material-icons-v50.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>
|
||||
<title>{% block title %}{{ SITE_TITLE }}{% endblock %}</title>
|
||||
<meta charset="utf-8" />
|
||||
@@ -44,12 +56,14 @@
|
||||
<lnbits-drawer v-if="g.user && !g.isPublicPage"></lnbits-drawer>
|
||||
{% block 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
|
||||
v-if="g.user && !g.isPublicPage"
|
||||
v-if="g.user && !g.isPublicPage && !$route.path.startsWith('/ext/')"
|
||||
></lnbits-wallet-new>
|
||||
<lnbits-header-wallets
|
||||
v-if="g.user && !g.isPublicPage"
|
||||
v-if="g.user && !g.isPublicPage && !$route.path.startsWith('/ext/')"
|
||||
></lnbits-header-wallets>
|
||||
<!-- block page content from static extensions -->
|
||||
<div
|
||||
|
||||
@@ -1,351 +1 @@
|
||||
{% extends "base.html" %} {% block styles %}
|
||||
<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 %}
|
||||
{% extends "base.html" %}
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
"js/components/extension-settings.js",
|
||||
"js/components/data-fields.js",
|
||||
"js/components.js",
|
||||
"js/wasm-extension-component.js",
|
||||
"js/init-app.js"
|
||||
],
|
||||
"css": [
|
||||
|
||||
Reference in New Issue
Block a user