Source: modules/Utils.js

/**
 * @module modules/Utils.js
 * @name Utils
 * @copyright 2023 3Liz
 * @license MPL-2.0
 */

import { NetworkError, HttpError, ResponseError } from './Errors.js';
import DOMPurify from 'dompurify';

/**
 * The main utils methods
 * @class
 * @name Utils
 */
export default class Utils {

    /**
     * Download a file provided as a string
     * @static
     * @param {string} text - file content
     * @param {string} fileType - file's MIME type
     * @param {string} fileName - file'name with extension
     */
    static downloadFileFromString(text, fileType, fileName) {
        var blob = new Blob([text], { type: fileType });

        var a = document.createElement('a');
        a.download = fileName;
        a.href = URL.createObjectURL(blob);
        a.dataset.downloadurl = [fileType, a.download, a.href].join(':');
        a.style.display = "none";
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        setTimeout(() => { URL.revokeObjectURL(a.href); }, 1500);
    }

    /**
     * Send an ajax POST request to download a file
     * @static
     * @param {string} url        - A string or any other object with a stringifier — including a URL object — that provides the URL of the resource to send the request to.
     * @param {Array} parameters  - Parameters that will be serialize as a Query string
     * @param {Function} callback - optional callback executed when download ends
     * @param {Function} errorCallback - optional callback executed when error event occurs
     */
    static downloadFile(url, parameters, callback, errorCallback) {
        var xhr = new XMLHttpRequest();
        xhr.open('POST', url, true);
        xhr.responseType = 'arraybuffer';
        xhr.onload = function () {
            if (this.status === 200) {
                var filename = "";
                var disposition = xhr.getResponseHeader('Content-Disposition');
                if (disposition && disposition.indexOf('filename') !== -1) {
                    var filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
                    var matches = filenameRegex.exec(disposition);
                    if (matches != null && matches[1]) filename = matches[1].replace(/['"]/g, '');
                }

                let type = xhr.getResponseHeader('Content-Type');

                // Firefox >= 98 opens blob in its pdf viewer
                // This is a hack to force download as in Chrome
                if (navigator.userAgent.toLowerCase().indexOf('firefox') > -1 && type == 'application/pdf') {
                    type = 'application/octet-stream';
                }
                const blob = new File([this.response], filename, { type: type });
                const downloadUrl = URL.createObjectURL(blob);

                if (filename) {
                    // use HTML5 a[download] attribute to specify filename
                    const a = document.createElement('a');
                    a.href = downloadUrl;
                    a.download = filename;
                    a.dispatchEvent(new MouseEvent('click'));
                } else {
                    window.open(downloadUrl);
                }

                setTimeout(() => URL.revokeObjectURL(downloadUrl), 100); // cleanup
            } else {
                // Execute callback if any
                if (typeof errorCallback === 'function') {
                    errorCallback(new HttpError('HTTP error: ' + this.status, this.status, url, {method:'POST', body:$.param(parameters, true)}));
                }
            }

            // Execute callback if any
            if (typeof callback === 'function') {
                callback();
            }
        };
        // Add error callback if any
        if (typeof errorCallback === 'function') {
            xhr.addEventListener("error", errorCallback);
        }
        xhr.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
        xhr.send($.param(parameters, true));
    }

    /**
     * Fetching a resource from the network, returning a promise that is fulfilled once the response is successful.
     * @static
     * @param {string} resource - This defines the resource that you wish to fetch. A string or any other object with a stringifier — including a URL object — that provides the URL of the resource you want to fetch.
     * @param {object} options  - An object containing any custom settings you want to apply to the request.
     * @returns {Promise} A Promise that resolves to a successful Response object (status in the range 200 – 299)
     * @throws {HttpError} In case of not successful response (status not in the range 200 – 299)
     * @throws {NetworkError} In case of catch exceptions
     * @see https://developer.mozilla.org/en-US/docs/Web/API/fetch
     * @see https://developer.mozilla.org/en-US/docs/Web/API/Response
     */
    static fetch(resource, options) {
        return fetch(resource, options).then(response => {
            if (response.ok) {
                return response;
            }

            return Promise.reject(new HttpError('HTTP error: ' + response.status, response.status, resource, options));
        }).catch(error => {
            if (error instanceof NetworkError) {
                return Promise.reject(error);
            }
            return Promise.reject(new NetworkError(error.message, resource, options));
        });
    }

    /**
     * Fetching a resource from the network, which is JSON or GeoJSON, returning a promise that resolves with the result of parsing the response body text as JSON.
     * @static
     * @param {string} resource - This defines the resource that you wish to fetch. A string or any other object with a stringifier — including a URL object — that provides the URL of the resource you want to fetch.
     * @param {object} options - An object containing any custom settings you want to apply to the request.
     * @returns {Promise} A Promise that resolves with the result of parsing the response body text as JSON.
     * @throws {ResponseError} In case of invalid content type (not application/json or application/vnd.geo+json) or Invalid JSON
     * @throws {HttpError} In case of not successful response (status not in the range 200 – 299)
     * @throws {NetworkError} In case of catch exceptions
     * @see https://developer.mozilla.org/en-US/docs/Web/API/fetch
     * @see https://developer.mozilla.org/en-US/docs/Web/API/Response
     */
    static fetchJSON(resource, options) {
        return Utils.fetch(resource, options).then(response => {
            const contentType = response.headers.get('Content-Type') || '';

            if (contentType.includes('application/json') ||
                contentType.includes('application/vnd.geo+json')) {
                return response.json().catch(error => {
                    return Promise.reject(new ResponseError('Invalid JSON: ' + error.message, response, resource, options));
                });
            }

            return Promise.reject(new ResponseError('Invalid content type: ' + contentType, response, resource, options));
        }).catch(error => {return Promise.reject(error)});
    }

    /**
     * Fetching a resource from the network, which is HTML, returning a promise that resolves with a text representation of the response body.
     * @static
     * @param {string} resource - This defines the resource that you wish to fetch. A string or any other object with a stringifier — including a URL object — that provides the URL of the resource you want to fetch.
     * @param {object} options - An object containing any custom settings you want to apply to the request.
     * @returns {Promise} A Promise that resolves with a text representation of the response body.
     * @throws {ResponseError} In case of invalid content type (not text/html)
     * @throws {HttpError} In case of not successful response (status not in the range 200 – 299)
     * @throws {NetworkError} In case of catch exceptions
     * @see https://developer.mozilla.org/en-US/docs/Web/API/fetch
     * @see https://developer.mozilla.org/en-US/docs/Web/API/Response
     */
    static fetchHTML(resource, options) {
        return Utils.fetch(resource, options).then(response => {
            const contentType = response.headers.get('Content-Type') || '';

            if (contentType.includes('text/html')) {
                return response.text().catch(error => {
                    return Promise.reject(new ResponseError('HTML error: ' + error.message, response, resource, options));
                });
            }

            return Promise.reject(new ResponseError('Invalid content type: ' + contentType, response, resource, options));
        }).catch(error => {return Promise.reject(error)});
    }

    /**
     * Get the corresponding resolution for the scale with meters per unit
     * @static
     * @param {number} scale         - The scale
     * @param {number} metersPerUnit - The meters per unit
     * @returns {number} The corresponding resolution
     * @see https://github.com/openlayers/ol2/blob/master/lib/OpenLayers/Util.js#L1101
     */
    static getResolutionFromScale(scale, metersPerUnit) {
        const inchesPerMeter = 1000 / 25.4;
        const DPI = 96;
        const resolution = scale / (inchesPerMeter * DPI * metersPerUnit);
        return resolution;
    }

    /**
     * Get the corresponding scale for the resolution with meters per unit
     * @static
     * @param {number} resolution    - The scale
     * @param {number} metersPerUnit - The meters per unit
     * @returns {number} The corresponding scale
     * @see getResolutionFromScale
     */
    static getScaleFromResolution(resolution, metersPerUnit) {
        const inchesPerMeter = 1000 / 25.4;
        const DPI = 96;
        const scale = resolution * inchesPerMeter * DPI * metersPerUnit;
        return scale;
    }

    static sanitizeGFIContent(content) {
        DOMPurify.addHook('afterSanitizeAttributes', node => {
            if (node.nodeName === 'IFRAME') {
                node.setAttribute('sandbox','allow-scripts allow-forms');
            }
        });
        return DOMPurify.sanitize(content, {
            ADD_TAGS: ['iframe'],
            ADD_ATTR: ['target'],
            CUSTOM_ELEMENT_HANDLING: {
                tagNameCheck: /^lizmap-/,
                attributeNameCheck: /crs|bbox|edition-restricted|layerid|layertitle|uniquefield|expressionfilter|withgeometry|sortingfield|sortingorder|draggable/,
            }
        });
    }
}