Source: src/js/views/maps/VisualizationPanelView.js

"use strict";

define([
  "backbone",
  "common/TrustedContentUtilities",
  "common/UriTemplateUtilities",
], (Backbone, trustedContent, UriTemplateUtilities) => {
  const BASE_CLASS = "visualization-panel";
  const CLASS_NAMES = {
    body: `${BASE_CLASS}__body`,
    closeButton: `${BASE_CLASS}__close-button`,
    header: `${BASE_CLASS}__header`,
    iframe: `${BASE_CLASS}__iframe`,
    open: `${BASE_CLASS}--open`,
    untrustedMessage: `${BASE_CLASS}__untrusted-message`,
    untrusted: `${BASE_CLASS}__untrusted`,
    untrustedLink: `${BASE_CLASS}__untrusted-link`,
  };

  const VIEW_TEMPLATE = `
    <div class="${CLASS_NAMES.header}">
      <button class="${CLASS_NAMES.closeButton} map-view__button" type="button" aria-label="Close visualization"><i class="icon-remove"></i></button>
    </div>
    <div class="${CLASS_NAMES.body}">
      <iframe
        class="${CLASS_NAMES.iframe}"
        title="Visualization"
      ></iframe>
      <div class="${CLASS_NAMES.untrusted}" style="display:none">
        <p class="${CLASS_NAMES.untrustedMessage}">This content cannot be displayed here because its source is not in the list of trusted sources.</p>
        <a class="${CLASS_NAMES.untrustedLink} map-view__button map-view__button--emphasis" target="_blank" rel="noopener noreferrer">
          <i class="icon icon-external-link"></i>
          Open in Browser
        </a>
      </div>
    </div>
  `;

  /**
   * @class VisualizationPanelView
   * @classdesc A full-screen overlay panel that displays an external
   * visualization application in an iframe above the map. Opened by clicking
   * an 'iframe' type button on a ViewfinderCardView card. Closed by clicking
   * the close button in the upper right, pressing the Escape key, or by clicking
   * another card action.
   *
   * Security: the iframe `src` is only set when the URL matches the app's
   * `trustedContentSources` configuration. Untrusted URLs are displayed as a
   * plain link instead.
   * @classcategory Views/Maps
   * @name VisualizationPanelView
   * @augments Backbone.View
   * @constructs VisualizationPanelView
   * @screenshot views/maps/VisualizationPanelView.png
   * @since 2.37.0
   */
  const VisualizationPanelView = Backbone.View.extend(
    /** @lends VisualizationPanelView.prototype */ {
      /**
       * The type of View this is
       * @type {string}
       */
      type: "VisualizationPanelView",

      /** @inheritdoc */
      className: BASE_CLASS,

      /**
       * The events this view will listen to and the associated function to
       * call.
       * @type {object}
       */
      events() {
        return {
          [`click .${CLASS_NAMES.closeButton}`]: "close",
        };
      },

      /**
       * Bind the Escape-key handler once so the same reference can be
       * added and removed from the document.
       */
      initialize() {
        this.handleEscapeKey = (e) => {
          if (e.key === "Escape") this.close();
        };
        this.handleMessage = (event) => {
          const iframe = this.el.querySelector(`.${CLASS_NAMES.iframe}`);
          if (!iframe || !iframe.contentWindow) return;

          if (
            typeof this.activeVisualizationOrigin === "string" &&
            this.activeVisualizationOrigin.length &&
            event.origin !== this.activeVisualizationOrigin
          ) {
            return;
          }

          if (
            !this.isSourceFromActiveIframe(iframe.contentWindow, event.source)
          ) {
            return;
          }

          const data = event?.data;
          if (!data || typeof data !== "object") return;
          if (data.type !== "mcui:state") return;

          const version = data.version == null ? 1 : Number(data.version);
          if (!Number.isFinite(version) || version !== 1) return;
          if (typeof data.url !== "string" || !data.url.length) return;

          try {
            if (new URL(data.url).origin !== this.activeVisualizationOrigin) {
              return;
            }
          } catch (_e) {
            return;
          }

          this.trigger("mcui:state", {
            action: this.activeVisualizationAction || null,
            url: data.url,
            version,
          });
        };
      },

      /**
       * Check whether a postMessage source window belongs to the active
       * iframe's browsing context. This accepts the iframe window itself and
       * same-origin descendant frames within that iframe.
       * @param {Window} iframeWindow The active iframe content window.
       * @param {WindowProxy|null} sourceWindow The postMessage source window.
       * @returns {boolean} `true` when sourceWindow is the iframe or a descendant.
       * @since 2.38.0
       */
      isSourceFromActiveIframe(iframeWindow, sourceWindow) {
        if (!iframeWindow || !sourceWindow) {
          return false;
        }

        if (sourceWindow === iframeWindow) {
          return true;
        }

        let currentWindow = sourceWindow;
        let hops = 0;
        while (currentWindow && hops < 20) {
          if (currentWindow === iframeWindow) {
            return true;
          }

          let parentWindow;
          try {
            parentWindow = currentWindow.parent;
          } catch (_e) {
            return false;
          }

          if (!parentWindow || parentWindow === currentWindow) {
            return false;
          }

          currentWindow = parentWindow;
          hops += 1;
        }

        return false;
      },

      /**
       * Resolve expected postMessage origin for the current iframe action.
       * @param {object|null} action The active iframe action object.
       * @param {string} resolvedUrl The URL currently loaded in the iframe.
       * @returns {string|null} Strict origin expected for postMessage events.
       * @since 2.38.0
       */
      getExpectedMessageOrigin(action, resolvedUrl) {
        const templateUrl =
          typeof action?.url === "string" ? action.url : resolvedUrl;
        if (typeof templateUrl !== "string" || !templateUrl.length) {
          return null;
        }

        const baseUrl = UriTemplateUtilities.getTemplateBaseUrl(templateUrl);

        try {
          return new URL(baseUrl || resolvedUrl).origin;
        } catch (_e) {
          return null;
        }
      },

      /**
       * Open the panel and load the given URL in the iframe. If the URL is
       * not trusted, show a plain link fallback instead.
       * @param {string|object} payload The URL string to load or an object
       * with `{ url, action }` for URL-template-aware visualizations.
       */
      open(payload) {
        const resolvedUrl =
          typeof payload === "string" ? payload : payload?.url || "";
        this.activeVisualizationAction =
          payload && typeof payload === "object"
            ? payload.action || null
            : null;
        this.activeVisualizationOrigin = this.getExpectedMessageOrigin(
          this.activeVisualizationAction,
          resolvedUrl,
        );

        const iframe = this.el.querySelector(`.${CLASS_NAMES.iframe}`);
        const untrustedMessage = this.el.querySelector(
          `.${CLASS_NAMES.untrustedMessage}`,
        );
        const untrusted = this.el.querySelector(`.${CLASS_NAMES.untrusted}`);
        const untrustedLink = this.el.querySelector(
          `.${CLASS_NAMES.untrustedLink}`,
        );

        document.addEventListener("keydown", this.handleEscapeKey);
        window.addEventListener("message", this.handleMessage);

        if (trustedContent.isTrustedUrl(resolvedUrl)) {
          iframe.setAttribute(
            "sandbox",
            trustedContent.getTrustedIframeSandbox(resolvedUrl),
          );
          iframe.src = resolvedUrl;
          iframe.style.display = "";
          untrusted.style.display = "none";
        } else {
          iframe.removeAttribute("sandbox");
          iframe.removeAttribute("src");
          iframe.style.display = "none";

          if (trustedContent.isHttpUrl(resolvedUrl)) {
            untrustedMessage.textContent =
              "This content cannot be displayed here because its source is not in the list of trusted sources.";
            untrustedLink.href = resolvedUrl;
            untrustedLink.textContent = resolvedUrl;
            untrustedLink.style.display = "";
          } else {
            untrustedMessage.textContent =
              "This content cannot be displayed here because the URL protocol is unsafe.";
            untrustedLink.removeAttribute("href");
            untrustedLink.textContent = "";
            untrustedLink.style.display = "none";
          }

          untrusted.style.display = "";
        }

        this.el.classList.add(CLASS_NAMES.open);
      },

      /**
       * Close the panel, stop the iframe, and fire a "close" event.
       */
      close() {
        const iframe = this.el.querySelector(`.${CLASS_NAMES.iframe}`);
        if (iframe) iframe.removeAttribute("src");
        this.el.classList.remove(CLASS_NAMES.open);
        document.removeEventListener("keydown", this.handleEscapeKey);
        window.removeEventListener("message", this.handleMessage);
        this.activeVisualizationAction = null;
        this.activeVisualizationOrigin = null;
        this.trigger("close");
      },

      /**
       * Cleanup global handlers if the view is destroyed while open.
       */
      onClose() {
        document.removeEventListener("keydown", this.handleEscapeKey);
        window.removeEventListener("message", this.handleMessage);
        const iframe = this.el.querySelector(`.${CLASS_NAMES.iframe}`);
        if (iframe) iframe.removeAttribute("src");
        this.activeVisualizationAction = null;
        this.activeVisualizationOrigin = null;
      },

      /**
       * Render the panel. The panel is hidden by default; call `open()` to
       * show it.
       * @returns {VisualizationPanelView} Returns the rendered view element.
       */
      render() {
        this.el.innerHTML = VIEW_TEMPLATE;
        return this;
      },
    },
  );

  return VisualizationPanelView;
});