diff options
Diffstat (limited to 'packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts')
-rw-r--r-- | packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts | 372 |
1 files changed, 372 insertions, 0 deletions
diff --git a/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts b/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts new file mode 100644 index 000000000..3b7cbcbb7 --- /dev/null +++ b/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts @@ -0,0 +1,372 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { CoreApiResponse, TalerError, TalerErrorCode } from "@gnu-taler/taler-util"; +import type { MessageFromBackend } from "./platform/api.js"; + +/** + * This will modify all the pages that the user load when navigating with Web Extension enabled + * + * Can't do useful integration since it run in ISOLATED (or equivalent) mode. + * + * If taler support is expected, it will inject a script which will complete the integration. + */ + +// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Chrome_incompatibilities#content_script_environment + +// ISOLATED mode in chromium browsers +// https://chromium.googlesource.com/chromium/src/+/master/third_party/blink/renderer/bindings/core/v8/V8BindingDesign.md#world +// X-Ray vision in Firefox +// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Sharing_objects_with_page_scripts#xray_vision_in_firefox + +// *** IMPORTANT *** + +// Content script lifecycle during navigation +// In Firefox: Content scripts remain injected in a web page after the user has navigated away, +// however, window object properties are destroyed. +// In Chrome: Content scripts are destroyed when the user navigates away from a web page. + +const documentDocTypeIsHTML = + window.document.doctype && window.document.doctype.name === "html"; +const suffixIsNotXMLorPDF = + !window.location.pathname.endsWith(".xml") && + !window.location.pathname.endsWith(".pdf"); +const rootElementIsHTML = + document.documentElement.nodeName && + document.documentElement.nodeName.toLowerCase() === "html"; +// const pageAcceptsTalerSupport = document.head.querySelector( +// "meta[name=taler-support]", +// ); + + + +function validateTalerUri(uri: string): boolean { + return ( + !!uri && (uri.startsWith("taler://") || uri.startsWith("taler+http://")) + ); +} + +function convertURIToWebExtensionPath(uri: string) { + const url = new URL( + chrome.runtime.getURL(`static/wallet.html#/taler-uri/${encodeURIComponent(uri)}`), + ); + return url.href; +} + +// safe check, if one of this is true then taler handler is not useful +// or not expected +const shouldNotInject = + !documentDocTypeIsHTML || + !suffixIsNotXMLorPDF || + // !pageAcceptsTalerSupport || + !rootElementIsHTML; + +const logger = { + debug: (...msg: any[]) => { }, + info: (...msg: any[]) => + console.log(`${new Date().toISOString()} TALER`, ...msg), + error: (...msg: any[]) => + console.error(`${new Date().toISOString()} TALER`, ...msg), +}; + +// logger.debug = logger.info + +/** + */ +function redirectToTalerActionHandler(element: HTMLMetaElement) { + const name = element.getAttribute("name") + if (!name) return; + if (name !== "taler-uri") return; + const uri = element.getAttribute("content"); + if (!uri) return; + + if (!validateTalerUri(uri)) { + logger.error(`taler:// URI is invalid: ${uri}`); + return; + } + + const walletPage = convertURIToWebExtensionPath(uri) + window.location.replace(walletPage) +} + +function injectTalerSupportScript(head: HTMLHeadElement, trusted: boolean) { + const meta = head.querySelector("meta[name=taler-support]") + if (!meta) return; + const content = meta.getAttribute("content"); + if (!content) return; + const features = content.split(",") + + const debugEnabled = meta.getAttribute("debug") === "true"; + const hijackEnabled = features.indexOf("uri") !== -1 + const talerApiEnabled = features.indexOf("api") !== -1 && trusted + + const scriptTag = document.createElement("script"); + scriptTag.setAttribute("async", "false"); + const url = new URL( + chrome.runtime.getURL("/dist/taler-wallet-interaction-support.js"), + ); + url.searchParams.set("id", chrome.runtime.id); + if (debugEnabled) { + url.searchParams.set("debug", "true"); + } + if (talerApiEnabled) { + url.searchParams.set("api", "true"); + } + if (hijackEnabled) { + url.searchParams.set("hijack", "true"); + } + scriptTag.src = url.href; + + try { + head.insertBefore(scriptTag, head.children.length ? head.children[0] : null); + } catch (e) { + logger.info("inserting link handler failed!"); + logger.error(e); + } +} + + +export interface ExtensionOperations { + isAutoOpenEnabled: { + request: void; + response: boolean; + }; + isDomainTrusted: { + request: { + domain: string; + }; + response: boolean; + }; +} + +export type MessageFromExtension<Op extends keyof ExtensionOperations> = { + channel: "extension"; + operation: Op; + payload: ExtensionOperations[Op]["request"]; +}; + +export type MessageResponse = CoreApiResponse; + +async function callBackground<Op extends keyof ExtensionOperations>( + operation: Op, + payload: ExtensionOperations[Op]["request"], +): Promise<ExtensionOperations[Op]["response"]> { + const message: MessageFromExtension<Op> = { + channel: "extension", + operation, + payload, + }; + + const response = await sendMessageToBackground(message); + if (response.type === "error") { + throw new Error(`Background operation "${operation}" failed`); + } + return response.result as any; +} + + +let nextMessageIndex = 0; +/** + * + * @param message + * @returns + */ +async function sendMessageToBackground<Op extends keyof ExtensionOperations>( + message: MessageFromExtension<Op>, +): Promise<MessageResponse> { + const messageWithId = { ...message, id: `id_${nextMessageIndex++ % 1000}` }; + + if (!chrome.runtime.id) { + return Promise.reject(TalerError.fromDetail(TalerErrorCode.WALLET_CORE_NOT_AVAILABLE, {})) + } + return new Promise<any>((resolve, reject) => { + logger.debug("send operation to the wallet background", message, chrome.runtime.id); + let timedout = false; + const timerId = setTimeout(() => { + timedout = true; + reject(TalerError.fromDetail(TalerErrorCode.GENERIC_TIMEOUT, { + requestMethod: "wallet", + requestUrl: message.operation, + timeoutMs: 20 * 1000, + })) + }, 20 * 1000); //five seconds + try { + chrome.runtime.sendMessage(messageWithId, (backgroundResponse) => { + if (timedout) { + return false; //already rejected + } + clearTimeout(timerId); + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError.message); + } else { + resolve(backgroundResponse); + } + // return true to keep the channel open + return true; + }); + } catch (e) { + console.log(e) + } + }); +} + +let notificationPort: chrome.runtime.Port | undefined; +function listenToWalletBackground(listener: (m: any) => void): () => void { + if (notificationPort === undefined) { + notificationPort = chrome.runtime.connect({ name: "notifications" }); + } + notificationPort.onMessage.addListener(listener); + function removeListener(): void { + if (notificationPort !== undefined) { + notificationPort.onMessage.removeListener(listener); + } + } + return removeListener; +} + +const loaderSettings = { + isAutoOpenEnabled: false, + isDomainTrusted: false, +} + +function start( + onTalerMetaTagFound: (listener: (el: HTMLMetaElement) => void) => void, + onHeadReady: (listener: (el: HTMLHeadElement) => void) => void +) { + // do not run everywhere, this is just expected to run on site + // that are aware of taler + if (shouldNotInject) return; + + const isAutoOpenEnabled_promise = callBackground("isAutoOpenEnabled", undefined).then(result => { + loaderSettings.isAutoOpenEnabled = result; + return result; + }) + const isDomainTrusted_promise = callBackground("isDomainTrusted", { + domain: window.location.origin + }).then(result => { + loaderSettings.isDomainTrusted = result; + return result; + }) + + onTalerMetaTagFound(async (el) => { + await isAutoOpenEnabled_promise; + if (!loaderSettings.isAutoOpenEnabled) { + return; + } + redirectToTalerActionHandler(el) + }) + + onHeadReady(async (el) => { + const trusted = await isDomainTrusted_promise + injectTalerSupportScript(el, trusted) + }) + + listenToWalletBackground((e: MessageFromBackend) => { + if (e.type === "web-extension" && e.notification.type === "settings-change") { + const settings = e.notification.currentValue + loaderSettings.isAutoOpenEnabled = settings.autoOpen + } + }) + +} + +function isCorrectMetaElement(el: HTMLMetaElement): boolean { + const name = el.getAttribute("name") + if (!name) return false; + if (name !== "taler-uri") return false; + const uri = el.getAttribute("content"); + if (!uri) return false; + return true +} + +/** + * Tries to find taler meta tag ASAP and report + * @param notify + * @returns + */ +function notifyWhenTalerUriIsFound(notify: (el: HTMLMetaElement) => void) { + if (document.head) { + const element = document.head.querySelector("meta[name=taler-uri]") + if (!element) return; + if (!(element instanceof HTMLMetaElement)) return; + + if (isCorrectMetaElement(element)) { + notify(element) + } + return; + } + const obs = new MutationObserver(async function (mutations) { + try { + mutations.forEach((mut) => { + if (mut.type === "childList") { + mut.addedNodes.forEach((added) => { + if (added instanceof HTMLMetaElement) { + if (isCorrectMetaElement(added)) { + notify(added) + obs.disconnect() + } + } + }); + } + }); + } catch (e) { + console.error(e) + } + }) + + obs.observe(document, { + childList: true, + subtree: true, + attributes: false, + }) + +} + +/** + * Tries to find HEAD tag ASAP and report + * @param notify + * @returns + */ +function notifyWhenHeadIsFound(notify: (el: HTMLHeadElement) => void) { + if (document.head) { + notify(document.head) + return; + } + const obs = new MutationObserver(async function (mutations) { + try { + mutations.forEach((mut) => { + if (mut.type === "childList") { + mut.addedNodes.forEach((added) => { + if (added instanceof HTMLHeadElement) { + notify(added) + obs.disconnect() + } + }); + } + }); + } catch (e) { + console.error(e) + } + }) + + obs.observe(document, { + childList: true, + subtree: true, + attributes: false, + }) +} + +start(notifyWhenTalerUriIsFound, notifyWhenHeadIsFound); |