Viewing File: /usr/local/cpanel/base/frontend/jupiter/filemanager/editors/html_editor.js

/*
#                                     Copyright 2025 WebPros International, LLC
#                                                           All rights reserved.
# copyright@cpanel.net                                         http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited
*/

/* --------------------------*/
/* DEFINE GLOBALS FOR LINT   */
/*--------------------------*/
/* global CPANEL: true      */
/* global YAHOO: true       */
/* global Jodit: true       */
/* --------------------------*/

// Ensure CPANEL exists
if (typeof CPANEL === 'undefined') {
    window.CPANEL = {};
}

CPANEL.joditEditor = (function () {

    /* --------------------*/
    /* Application State  */
    /* --------------------*/
    var appData;
    var joditInstance;

    /**
     * Initialize Jodit editor
     *
     * @method initialize
     * @param  {Object} config configuration object for Jodit
     * @param {Object} data additional data for editor (file metadata, etc.)
     */
    var initialize = function (config, data) {
        appData = data;

        // Get the textarea element
        var textareaEl = document.getElementById(config.elementId || 'jodit-editor');
        if (!textareaEl) {
            console.error('Jodit editor element not found:', config.elementId || 'jodit-editor');
            return;
        }

        // Clear the textarea first to ensure clean state
        textareaEl.value = '';

        joditInstance = Jodit.make(textareaEl, config.joditOptions || {});

        // Set the content from appData after Jodit is fully initialized
        setTimeout(async function () {
            if (appData && appData.fileContentInfo && appData.fileContentInfo.content) {
                var content = appData.fileContentInfo.content;

                content = injectBaseTag(content, appData.baseUrl);

                // Fetch CSS files via UAPI and inline them into the content string
                // BEFORE passing to Jodit so styles survive any iframe re-renders.
                content = await inlineCSSIntoContent(
                    content,
                    appData.baseUrl,
                    appData.baseDir,
                    appData.securityToken
                );

                // Set content using a single, reliable method to avoid duplication
                try {
                    // Ensure editor is ready and clear any existing content
                    if (joditInstance && joditInstance.isReady) {
                        joditInstance.value = content;
                    } else {
                        // If editor is not ready, wait a bit more and try again
                        var delayedContent = content;
                        setTimeout(function () {
                            if (joditInstance) {
                                joditInstance.value = delayedContent;
                            }
                        }, 200);
                    }

                } catch (e) {
                    console.error('Error setting content:', e);
                    // Fallback: try setting via textarea
                    var textarea = document.getElementById(config.elementId || 'jodit-editor');
                    if (textarea) {
                        textarea.value = content;
                    }
                }

                } else {
                    // Set empty content if no data is available
                    joditInstance.value = '';
                }
        }, 150);

        // Set up event handlers
        setupEventHandlers();

        // Show the form if it was hidden
        showEditorForm();
    };

    /**
     * Set up event handlers for the Jodit editor
     *
     * @method setupEventHandlers
     */
    function setupEventHandlers() {
        if (!joditInstance) return;

        // Set up keyboard shortcuts
        joditInstance.e.on('keydown', function (e) {
            if ((e.ctrlKey || e.metaKey) && e.key === 's') {
                e.preventDefault();
                saveFile();
            }
        });

        // Track dirty state when content changes
        joditInstance.__originalContent = joditInstance.value;
        joditInstance.__isDirty = false;
        joditInstance.__saveInProgress = false;
        joditInstance.__reEnabling = false;

        // Use a debounced change handler to avoid false positives
        var changeTimeout;
        var changeHandler = function () {
            // Ignore changes during save operations or re-enabling
            if (joditInstance && !joditInstance.__saveInProgress && !joditInstance.__reEnabling) {
                // Clear any existing timeout
                if (changeTimeout) {
                    clearTimeout(changeTimeout);
                }

                // Set a small delay to avoid marking dirty during save operations
                changeTimeout = setTimeout(function () {
                    if (!joditInstance.__saveInProgress && !joditInstance.__reEnabling) {
                        joditInstance.__isDirty = true;
                    }
                }, 100);
            }
        };

        // Store the change handler for later use
        joditInstance.__changeHandler = changeHandler;
        joditInstance.e.on('change', changeHandler);
    }

    /**
     * Show the editor form
     *
     * @method showEditorForm
     */
    function showEditorForm() {
        var form = document.getElementById(appData.formID || 'htmlEditorForm');
        if (form) {
            form.className = form.className.replace('cpanelHide', 'cpanelShow');
        }
    }

    /**
     * Disable the editor during save operations
     *
     * @method disableEditor
     */
    function disableEditor() {
        if (!joditInstance) return;

        try {
            // Method 1: Set readonly mode
            joditInstance.setReadOnly(true);

            // Method 2: Disable the editor container
            var editorContainer = joditInstance.container;
            if (editorContainer) {
                editorContainer.style.pointerEvents = 'none';
                editorContainer.style.opacity = '0.6';
                editorContainer.setAttribute('data-saving', 'true');
            }

            // Method 3: Disable toolbar buttons
            var toolbar = joditInstance.toolbar;
            if (toolbar && toolbar.container) {
                toolbar.container.style.pointerEvents = 'none';
            }

        } catch (e) {
            // Could not disable editor
        }
    }

    /**
     * Enable the editor after save operations
     *
     * @method enableEditor
     */
    function enableEditor() {
        if (!joditInstance) {
            return;
        }

        try {
            // Temporarily remove change event handler to prevent false dirty marking
            if (joditInstance.__changeHandler) {
                joditInstance.e.off('change', joditInstance.__changeHandler);
            }

            // Temporarily disable change event handling during re-enable
            joditInstance.__reEnabling = true;

            // Method 1: Remove readonly mode
            joditInstance.setReadOnly(false);

            // Method 2: Re-enable the editor container
            var editorContainer = joditInstance.container;
            if (editorContainer) {
                editorContainer.style.pointerEvents = '';
                editorContainer.style.opacity = '';
                editorContainer.removeAttribute('data-saving');
            }

            // Method 3: Re-enable toolbar buttons
            var toolbar = joditInstance.toolbar;
            if (toolbar && toolbar.container) {
                toolbar.container.style.pointerEvents = '';
            }

            // Re-add the change handler and clear flags after a delay to allow Jodit to settle
            setTimeout(function () {
                if (joditInstance) {
                    joditInstance.__reEnabling = false;

                    // Re-add the change event handler
                    if (joditInstance.__changeHandler) {
                        joditInstance.e.on('change', joditInstance.__changeHandler);
                    }
                }
            }, 300);

        } catch (e) {
            // Make sure to clear the flag and restore handler even on error
            if (joditInstance) {
                joditInstance.__reEnabling = false;
                if (joditInstance.__changeHandler) {
                    joditInstance.e.on('change', joditInstance.__changeHandler);
                }
            }
        }
    }



    /**
     * Save the file content
     *
     * @method saveFile
     */
    function saveFile() {
        if (window.mixpanel) {
            window.mixpanel.track('HTML-Editor-file-save');
        }
        var Dynamic_Notice = CPANEL.ajax.Dynamic_Notice;
        if (!joditInstance) {
            notice = new Dynamic_Notice({
                level: 'error',
                content: LOCALE.maketext('Editor not initialized.')
            });
            return;
        }

        // Set save in progress flag to prevent dirty marking during save
        joditInstance.__saveInProgress = true;

        // Disable the editor during save.
        // **NOTE**: We must do this BEFORE inspecting/copying content from the Jodit instance,
        // otherwise, the content will have 'contenteditable=true' attributes injected (CPANEL-50329).
        disableEditor();

        var content = joditInstance.value;
        if (!content || content.trim() === '') {
            notice = new Dynamic_Notice({
                level: 'warning',
                content: LOCALE.maketext('No content to save.')
            });
            return;
        }

        // Show saving status
        notice = new Dynamic_Notice({
            level: 'info',
            content: LOCALE.maketext('Saving file …')
        });
        updateSaveButton(true, LOCALE.maketext('Saving...'));



        // Call the API to save the file with restored content
        _saveFile(content);
    }



    /**
     * API call to save the file content
     *
     * @method _saveFile
     * @private
     * @param {String} content File content
     */
    function _saveFile(content) {
        var Dynamic_Notice = CPANEL.ajax.Dynamic_Notice;
        var fileMetaData = appData.fileMetaData;

        var callback = {
            success: function (response) {
                _saveSuccess(response);
            },
            failure: function (response) {
                _saveFailure(response);
            }
        };

        // Add a timeout to prevent getting stuck
        var saveTimeout = setTimeout(function () {
            notice = new Dynamic_Notice({
                level: 'error',
                content: LOCALE.maketext('Save operation timed out.')
            });
            enableEditor();
            updateSaveButton(false, LOCALE.maketext('Save File'));
            if (joditInstance) {
                joditInstance.__saveInProgress = false;
            }
        }, 30000);

        // Store timeout so we can clear it on success/failure
        joditInstance.__saveTimeout = saveTimeout;

        // Check if CPANEL.api is available
        if (typeof CPANEL === 'undefined' || typeof CPANEL.api !== 'function') {
            clearTimeout(saveTimeout);
            _saveFailure({ error: 'CPANEL.api is not available' });
            return;
        }

        try {
            if (appData._baseTagInjected) {
                content = stripInjectedBaseTag(content, appData.baseUrl);
            }
            content = stripInjectedStyles(content);

            CPANEL.api({
                version: 3,
                module: "Fileman",
                func: "save_file_content",
                data: {
                    dir: fileMetaData.dirPath,
                    file: fileMetaData.fileName,
                    content: content,
                    to_charset: fileMetaData.charset,
                    fallback: true,
                    html: true
                },
                callback: callback
            });

        } catch (e) {
            clearTimeout(saveTimeout);
            _saveFailure({ error: 'API call failed: ' + e.message });
        }
    }

    /**
     * Success callback for save operation
     *
     * @method _saveSuccess
     * @private
     * @param {Object} o Response object
     */
    function _saveSuccess(o) {
        try {
            var Dynamic_Notice = CPANEL.ajax.Dynamic_Notice;
            // Clear the save timeout
            if (joditInstance && joditInstance.__saveTimeout) {
                clearTimeout(joditInstance.__saveTimeout);
                joditInstance.__saveTimeout = null;
            }

            updateSaveButton(false, LOCALE.maketext('Save File'));

            if (o && o.cpanel_status) {
                notice = new Dynamic_Notice({
                    level: 'success',
                    content: LOCALE.maketext('File saved successfully!')
                });

                // Mark as clean and clear save-in-progress flag BEFORE re-enabling
                if (joditInstance) {
                    // Clear save in progress flag first
                    joditInstance.__saveInProgress = false;

                    // Reset our custom dirty flag (this is what we actually check)
                    joditInstance.__isDirty = false;

                    // Update original content to current content
                    joditInstance.__originalContent = joditInstance.value;
                }

                // Re-enable the editor AFTER clearing dirty state
                enableEditor();
            } else {
                var errorMsg = LOCALE.maketext('Failed to save file.');
                if (o.cpanel_error) {
                    errorMsg += ': ' + o.cpanel_error;
                }
                notice = new Dynamic_Notice({
                    level: 'error',
                    content: errorMsg
                });
                enableEditor(); // Re-enable editor on error too
            }
        } catch (e) {
            notice = new Dynamic_Notice({
                level: 'error',
                content: LOCALE.maketext('Error processing save response.')
            });
            enableEditor(); // Re-enable editor on error too
        }
    }

    /**
     * Failure callback for save operation
     *
     * @method _saveFailure
     * @private
     * @param {Object} o Response object
     */
    function _saveFailure() {
        var Dynamic_Notice = CPANEL.ajax.Dynamic_Notice;
        // Clear the save timeout
        if (joditInstance && joditInstance.__saveTimeout) {
            clearTimeout(joditInstance.__saveTimeout);
            joditInstance.__saveTimeout = null;
        }

        updateSaveButton(false, LOCALE.maketext('Save File'));

        notice = new Dynamic_Notice({
            level: 'error',
            content: LOCALE.maketext('Network error while saving file. Please try again.')
        });

        // Re-enable the editor even on failure
        enableEditor();

        // Clear save in progress flag even on failure
        if (joditInstance) {
            joditInstance.__saveInProgress = false;
        }
    }

    /**
     * Preview the file content
     *
     * @method previewFile
     */
    function previewFile() {
        var Dynamic_Notice = CPANEL.ajax.Dynamic_Notice;
        if (!joditInstance) {
            notice = new Dynamic_Notice({
                level: 'error',
                content: LOCALE.maketext('Editor not initialized.')
            });
            return;
        }

        var content = joditInstance.value;
        var previewWindow = window.open('', '_blank', 'width=800,height=600,scrollbars=yes,resizable=yes');

        if (previewWindow) {
            previewWindow.document.write(content);
            previewWindow.document.close();
        } else {
            notice = new Dynamic_Notice({
                level: 'error',
                content: LOCALE.maketext('Could not open preview window. Please check popup blocker settings.')
            });
        }
    }

    /**
     * Update save button state
     *
     * @method updateSaveButton
     * @param {Boolean} disabled Whether the button should be disabled
     * @param {String} text The button text
     */
    function updateSaveButton(disabled, text) {
        var saveButton = document.getElementById('save-button');
        if (saveButton) {
            saveButton.disabled = disabled;
            saveButton.textContent = text;
        }
    }

    /**
     * Handle page unload to warn about unsaved changes
     *
     * @method handleBeforeUnload
     */
    function handleBeforeUnload(e) {
        // Only warn if we have our custom dirty flag set
        // Ignore Jodit's internal dirty state as it can be unreliable
        if (joditInstance && joditInstance.__isDirty === true) {
            var confirmationMessage = 'You have unsaved changes. Are you sure you want to leave?';
            e.returnValue = confirmationMessage;
            return confirmationMessage;
        }
        // If no custom dirty flag, allow navigation without warning
    }

    // Set up beforeunload handler
    window.addEventListener('beforeunload', handleBeforeUnload);

    /**
     * Force recovery from stuck save state
     *
     * @method forceRecovery
     */
    function forceRecovery() {
        var Dynamic_Notice = CPANEL.ajax.Dynamic_Notice;

        if (joditInstance) {
            // Clear all save-related flags
            joditInstance.__saveInProgress = false;
            joditInstance.__reEnabling = false;

            // Clear any timeouts
            if (joditInstance.__saveTimeout) {
                clearTimeout(joditInstance.__saveTimeout);
                joditInstance.__saveTimeout = null;
            }

            // Force enable the editor
            enableEditor();

            // Reset button state
            updateSaveButton(false, LOCALE.maketext('Save File'));

            notice = new Dynamic_Notice({
                level: 'info',
                content: LOCALE.maketext('Editor state reset.')
            });
        }
    }

    // Add UX helper methods to CPANEL.joditEditor
    /**
     * Inject a <base> tag into the HTML content so the browser resolves all
     * relative URLs (CSS images, fonts) against the file's actual web location
     * rather than the cPanel editor URL.
     *
     * Only injects when the content has a valid HTTPS base URL and no existing
     * <base> tag. Sets appData._baseTagInjected = true so the tag can be
     * stripped on save.
     *
     * @param {string} content HTML content to modify
     * @param {string} baseUrl Web-accessible URL of the directory containing the file
     * @returns {string} Modified HTML content
     */
    function injectBaseTag(content, baseUrl) {
        if (!baseUrl || !/^https?:\/\//i.test(baseUrl) || !content) {
            return content;
        }

        // Leave any author-supplied <base> tag alone.
        if (/<base\b[^>]*>/i.test(content)) {
            return content;
        }

        var baseTag = '<base href="' + baseUrl + '">';
        appData._baseTagInjected = true;

        // Prefer inserting right after the opening <head> tag.
        if (/<head\b[^>]*>/i.test(content)) {
            return content.replace(/(<head\b[^>]*>)/i, '$1\n    ' + baseTag);
        }

        // No <head> tag present — prepend to the document.
        return baseTag + '\n' + content;
    }

    /**
     * Remove the <base> tag that was injected by injectBaseTag() before saving
     * so the file on disk is not modified beyond what the user intended.
     *
     * @param {string} content HTML content containing the injected base tag
     * @param {string} baseUrl The href value that was injected
     * @returns {string} Content with the injected base tag removed
     */
    function stripInjectedBaseTag(content, baseUrl) {
        if (!baseUrl || !content) {
            return content;
        }

        var escaped = baseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        return content.replace(new RegExp('[ \\t]*<base href="' + escaped + '">\\n?', 'i'), '');
    }

    /**
     * Remove any <style> elements that were injected by fixIframeStyles() before
     * saving so the on-disk file retains its original <link> stylesheet elements.
     *
     * @param {string} content HTML content that may contain injected <style> blocks
     * @returns {string} Content with injected <style> blocks removed
     */
    function stripInjectedStyles(content) {
        if (!content) {
            return content;
        }
        return content.replace(/<style\b[^>]*\bdata-cpanel-injected\b[^>]*>[\s\S]*?<\/style>\n?/gi, '');
    }

    /**
     * Fetch each <link rel="stylesheet"> from the user's domain via the cPanel
     * Fileman API and inline the CSS as a <style> block in the HTML content
     * string, immediately after each <link> tag.
     *
     * This works around cross-origin failures that occur when the user's website
     * domain is not reachable from the browser (e.g., the domain only resolves
     * within the hosting server's network).  All UAPI requests go to cPanel on
     * the same origin, so no CORS headers are needed.
     *
     * Operating on the content string (rather than the live iframe DOM) ensures
     * the inlined styles survive Jodit's internal afterChange re-renders.
     *
     * Injected <style> blocks carry a data-cpanel-injected attribute so that
     * stripInjectedStyles() can remove them before the file is written to disk,
     * preserving the original <link> tags.
     *
     * @param {string} content    HTML content string
     * @param {string} baseUrl    Website base URL (e.g. https://example.com/subdir/)
     * @param {string} baseDir    Filesystem document root (e.g. /home/user/public_html)
     * @param {string} secToken   cPanel security token (e.g. /cpsessXXXXXX)
     * @returns {Promise<string>} HTML content with inlined CSS
     */
    async function inlineCSSIntoContent(content, baseUrl, baseDir, secToken) {
        if (!content || !baseUrl || !/^https?:\/\//i.test(baseUrl) || !baseDir || !secToken) {
            return content;
        }

        var baseUrlParsed;
        var domainOrigin;
        try {
            baseUrlParsed = new URL(baseUrl);
            domainOrigin = baseUrlParsed.origin; // e.g. https://cptest.tld
        } catch (e) {
            return content;
        }

        // baseUrl may have a subpath when the website lives in a subdirectory
        // (e.g. https://cptest.tld/test.cptest.tld/).  CSS that uses root-relative
        // paths (/webcard/static/…) is relative to that site root, not the domain
        // root, so we compute an explicit siteRoot for those paths.
        var basePathname = baseUrlParsed.pathname; // e.g. "/" or "/test.cptest.tld/"
        var siteRoot = baseDir + basePathname.replace(/\/$/, ''); // e.g. /home/user/public_html/test.cptest.tld

        // Find all <link rel="stylesheet" href="..."> tags in the HTML string.
        var linkRegex = /<link\b[^>]*\brel=["']stylesheet["'][^>]*>/gi;
        var items = [];
        var match;
        while ( (match = linkRegex.exec(content)) !== null ) {
            var hrefMatch = match[0].match(/\bhref=["']([^"']+)["']/i);
            if (hrefMatch) {
                items.push({ tag: match[0], href: hrefMatch[1] });
            }
        }

        for (var i = 0; i < items.length; i++) {
            var item = items[i];
            var href = item.href;

            if (/^(data:|blob:|javascript:)/i.test(href)) {
                continue;
            }

            // Resolve to absolute URL and confirm it is on the user's domain.
            var absoluteUrl;
            try {
                absoluteUrl = new URL(href, baseUrl).href;
            } catch (e) {
                continue;
            }

            if (!absoluteUrl.startsWith(domainOrigin + '/')) {
                continue;
            }

            // Strip query string — we need the real filesystem path.
            var urlPath = absoluteUrl.slice(domainOrigin.length).split('?')[0];

            // Use baseDir for paths already scoped under basePathname (relative
            // URLs resolved against baseUrl), otherwise use siteRoot so that
            // root-relative paths (/webcard/…) resolve inside the site directory.
            var fsBase = urlPath.startsWith(basePathname) ? baseDir : siteRoot;
            var fsPath = fsBase + urlPath;
            var lastSlash = fsPath.lastIndexOf('/');
            var fsDir = fsPath.substring(0, lastSlash);
            var fsFile = fsPath.substring(lastSlash + 1);

            // Skip filenames the UAPI would reject.
            if (!fsFile || /[/<>;]/.test(fsFile)) {
                continue;
            }

            try {
                var apiUrl = secToken + '/execute/Fileman/get_file_content?' +
                    'dir=' + encodeURIComponent(fsDir) +
                    '&file=' + encodeURIComponent(fsFile) +
                    '&charset=utf-8' +
                    '&update_html_document_encoding=0';

                var response = await fetch(apiUrl);
                if (!response.ok) {
                    continue;
                }

                var json = await response.json();
                if (!json || json.status !== 1 || !json.data || typeof json.data.content !== 'string') {
                    continue;
                }

                // Insert a <style> block immediately after the original <link>.
                // Use replace with a function to avoid '$' special replacement issues.
                var styleBlock = '<style data-cpanel-injected="' + fsFile + '">\n' + json.data.content + '\n</style>';
                content = content.replace(item.tag, function () { return item.tag + '\n' + styleBlock; });

            } catch (e) {
                // Network or parse error — leave the original <link> element.
            }
        }

        // Append a small bottom-padding rule so the last line of content is
        // always fully visible in the editor (not clipped by the viewport edge).
        // The data-cpanel-injected attribute ensures stripInjectedStyles() removes
        // this block before the file is written to disk.
        var paddingBlock = '<style data-cpanel-injected="cpanel-editor-padding">\nbody { padding-bottom: 80px !important; }\n</style>';
        content = content.replace(/<\/body>/i, paddingBlock + '\n</body>');

        return content;
    }

    function showUploadProgress(files) {
        if (typeof JoditUXHelpers !== 'undefined') {
            return JoditUXHelpers.showProgress(files);
        } else {
            console.warn('JoditUXHelpers not available');
            return [];
        }
    }

    function updateUploadProgress(fileId, progress, status) {
        if (typeof JoditUXHelpers !== 'undefined') {
            JoditUXHelpers.updateProgress(fileId, progress, status);
        } else {
            console.warn('JoditUXHelpers not available');
        }
    }

    function hideUploadProgress() {
        if (typeof JoditUXHelpers !== 'undefined') {
            JoditUXHelpers.hideProgress();
        } else {
            console.warn('JoditUXHelpers not available');
        }
    }

    function showUploadToast(message, type) {
        if (typeof JoditUXHelpers !== 'undefined') {
            JoditUXHelpers.showToast(message, type);
        } else {
            console.warn('JoditUXHelpers not available');
        }
    }

    // Public API
    return {
        initialize: initialize,
        saveFile: saveFile,
        previewFile: previewFile,
        disableEditor: disableEditor,
        enableEditor: enableEditor,
        forceRecovery: forceRecovery,
        showUploadProgress: showUploadProgress,
        updateUploadProgress: updateUploadProgress,
        hideUploadProgress: hideUploadProgress,
        showUploadToast: showUploadToast,
        getEditor: function () {
            return joditInstance;
        },
        isDirty: function () {
            return joditInstance && joditInstance.__isDirty === true;
        },

    };

})();
Back to Directory