/* 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 */ import { CoreApiResponse, TalerError, TalerErrorCode, } from "@gnu-taler/taler-util"; import type { MessageFromBackend } from "./platform/api.js"; // FIXME: mem leak problems // import { encodeCrockForURI } from "@gnu-taler/web-util/browser"; /** * 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( // FIXME: mem leak problems // `static/wallet.html#/taler-uri/${encodeCrockForURI(uri)}`, `static/wallet.html#/taler-uri-simple/${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 = { channel: "extension"; operation: Op; payload: ExtensionOperations[Op]["request"]; }; export type MessageResponse = CoreApiResponse; async function callBackground( operation: Op, payload: ExtensionOperations[Op]["request"], ): Promise { const message: MessageFromExtension = { 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( message: MessageFromExtension, ): Promise { const messageWithId = { ...message, id: `ld:${nextMessageIndex++ % 1000}` }; if (!chrome.runtime.id) { return Promise.reject( TalerError.fromDetail(TalerErrorCode.WALLET_CORE_NOT_AVAILABLE, {}), ); } return new Promise((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);