wikimedia/mediawiki-extensions-VisualEditor

View on GitHub
modules/ve-mw/preinit/ve.init.mw.ArticleTargetSaver.js

Summary

Maintainability
C
7 hrs
Test Coverage
/*!
 * VisualEditor MediaWiki ArticleTargetSaver.
 *
 * @copyright See AUTHORS.txt
 * @license The MIT License (MIT); see LICENSE.txt
 */

/**
 * Target saver.
 *
 * Light-weight saver.
 *
 * @class mw.libs.ve.targetSaver
 * @singleton
 * @hideconstructor
 */
( function () {
    mw.libs.ve = mw.libs.ve || {};

    mw.libs.ve.targetSaver = {
        /**
         * Preload the library required for deflating so the user doesn't
         * have to wait when postHtml is called.
         */
        preloadDeflate: function () {
            mw.loader.load( 'mediawiki.deflate' );
        },

        /**
         * Compress a string with deflate.
         *
         * @param {string} html HTML to deflate
         * @return {jQuery.Promise} Promise resolved with deflated HTML
         */
        deflate: function ( html ) {
            return mw.loader.using( 'mediawiki.deflate' ).then( () => mw.deflate( html ) );

        },

        /**
         * Get HTML to send to Parsoid.
         *
         * If the document was generated from scratch (e.g. inside VisualEditor's converter), the
         * source document can be passed in to transplant the head tag, as well as the attributes
         * on the html and body tags.
         *
         * @param {HTMLDocument} newDoc Document generated by ve.dm.Converter. Will be modified.
         * @param {HTMLDocument} [oldDoc] Old document to copy attributes from.
         * @return {string} Full HTML document
         */
        getHtml: function ( newDoc, oldDoc ) {
            function copyAttributes( from, to ) {
                Array.prototype.forEach.call( from.attributes, ( attr ) => {
                    to.setAttribute( attr.name, attr.value );
                } );
            }

            if ( oldDoc ) {
                // Copy the head from the old document
                for ( let i = 0, len = oldDoc.head.childNodes.length; i < len; i++ ) {
                    newDoc.head.appendChild( oldDoc.head.childNodes[ i ].cloneNode( true ) );
                }
                // Copy attributes from the old document for the html, head and body
                copyAttributes( oldDoc.documentElement, newDoc.documentElement );
                copyAttributes( oldDoc.head, newDoc.head );
                copyAttributes( oldDoc.body, newDoc.body );
            }

            // Filter out junk that may have been added by browser plugins
            $( newDoc )
                .find( [
                    'script', // T54884, T65229, T96533, T103430
                    'noscript', // T144891
                    'object', // T65229
                    'style:not( [ data-mw ] ):not( [ data-mw-deduplicate ] )', // T55252, but allow <style data-mw(-deduplicate)/> e.g. TemplateStyles T188143
                    'embed', // T53521, T54791, T65121
                    'a[href^="javascript:"]', // T200971
                    'img[src^="data:"]', // T192392
                    'div[id="myEventWatcherDiv"]', // T53423
                    'div[id="sendToInstapaperResults"]', // T63776
                    'div[id="kloutify"]', // T69006
                    'div[id^="mittoHidden"]', // T70900
                    'div.hon.certificateLink', // HON (T209619)
                    'div.donut-container', // Web of Trust (T189148)
                    'div.shield-container' // Web of Trust (T297862)
                ].join( ',' ) )
                .each( ( j, el ) => {
                    function truncate( text, l ) {
                        return text.length > l ? text.slice( 0, l ) + '…' : text;
                    }
                    const errorMessage = 'DOM content matching deny list found:\n' + truncate( el.outerHTML, 100 ) +
                        '\nContext:\n' + truncate( el.parentNode.outerHTML, 200 );
                    mw.log.error( errorMessage );
                    const err = new Error( errorMessage );
                    err.name = 'VeDomDenyListWarning';
                    mw.errorLogger.logError( err, 'error.visualeditor' );
                    $( el ).remove();
                } );

            // data-mw-section-id is copied to headings by mw.libs.ve.unwrapParsoidSections
            // Remove these to avoid triggering selser.
            $( newDoc ).find( '[data-mw-section-id]:not( section )' ).removeAttr( 'data-mw-section-id' );

            // Deduplicate styles (we re-duplicated them in ve.init.mw.Target.static.parseDocument)
            // to let selser recognize the nodes and avoid dirty diffs.
            mw.libs.ve.deduplicateStyles( newDoc.body );

            // Add doctype manually
            // ve.properOuterHtml is loaded separately in ve.utils.parsing.js
            // eslint-disable-next-line no-undef
            return '<!doctype html>' + ve.properOuterHtml( newDoc.documentElement );
        },

        /**
         * Serialize and deflate an HTML document
         *
         * @param {HTMLDocument} doc Document generated by ve.dm.Converter. Will be modified.
         * @param {HTMLDocument} [oldDoc] Old document to copy attributes from.
         * @return {jQuery.Promise} Promise resolved with deflated HTML
         */
        deflateDoc: function ( doc, oldDoc ) {
            return this.deflate( this.getHtml( doc, oldDoc ) );
        },

        /**
         * Post an HTML document to the API.
         *
         * Serializes the document to HTML, deflates it, then passes to #postHtml.
         *
         * @param {HTMLDocument} doc Document to save
         * @param {Object} [extraData] Extra data to send to the API
         * @param {Object} [options]
         * @return {jQuery.Promise} Promise which resolves if the post was successful
         */
        saveDoc: function ( doc, extraData, options ) {
            return this.deflateDoc( doc ).then( ( html ) => this.postHtml(
                html,
                null,
                extraData,
                options
            ) );
        },

        /**
         * Post wikitext to the API.
         *
         * By default uses action=visualeditoredit, paction=save.
         *
         * @param {string} wikitext Wikitext to post. Deflating is optional but recommended.
         * @param {Object} [extraData] Extra data to send to the API
         * @param {Object} [options]
         * @param {mw.Api} [options.api] Api to use
         * @param {Function} [options.now] Function returning current time in milliseconds for tracking, e.g. ve.now
         * @param {Function} [options.track] Tracking function
         * @param {string} [options.eventName] Event name for tracking
         * @return {jQuery.Promise} Promise which resolves with API save data, or rejects with error details
         */
        postWikitext: function ( wikitext, extraData, options ) {
            return this.postContent( $.extend( { wikitext: wikitext }, extraData ), options );
        },

        /**
         * Post HTML to the API.
         *
         * By default uses action=visualeditoredit, paction=save.
         *
         * @param {string} html HTML to post. Deflating is optional but recommended.
         *  Should be included for retries even if a cache key is provided.
         * @param {string} [cacheKey] Optional cache key of HTML stashed on server.
         * @param {Object} [extraData] Extra data to send to the API
         * @param {Object} [options]
         * @return {jQuery.Promise} Promise which resolves with API save data, or rejects with error details
         */
        postHtml: function ( html, cacheKey, extraData, options ) {
            options = options || {};
            let data;
            if ( cacheKey ) {
                data = $.extend( { cachekey: cacheKey }, extraData );
            } else {
                data = $.extend( { html: html }, extraData );
            }
            return this.postContent( data, options ).then(
                null,
                ( code, response ) => {
                    // This cache key is evidently bad, clear it
                    if ( options.onCacheKeyFail ) {
                        options.onCacheKeyFail();
                    }
                    if ( code === 'badcachekey' ) {
                        // If the cache key failed, try again without the cache key
                        return this.postHtml(
                            html,
                            null,
                            extraData,
                            options
                        );
                    }
                    // Failed for some other reason - let caller handle it.
                    return $.Deferred().reject( code, response ).promise();
                }
            );
        },

        /**
         * Post content to the API, using mw.Api#postWithToken to retry automatically when encountering
         * a 'badtoken' error.
         *
         * By default uses action=visualeditoredit, paction=save.
         *
         * @param {string} data Content data
         * @param {Object} [options]
         * @param {mw.Api} [options.api] Api to use
         * @param {Function} [options.now] Function returning current time in milliseconds for tracking, e.g. ve.now
         * @param {Function} [options.track] Tracking function
         * @param {string} [options.eventName] Event name for tracking
         * @return {jQuery.Promise} Promise which resolves with API save data, or rejects with error details
         */
        postContent: function ( data, options ) {
            options = options || {};
            const api = options.api || new mw.Api();

            let start;
            if ( options.now ) {
                start = options.now();
            }

            data = $.extend(
                {
                    action: 'visualeditoredit',
                    paction: 'save',
                    useskin: mw.config.get( 'skin' ),
                    // Same as OO.ui.isMobile()
                    mobileformat: !!mw.config.get( 'wgMFMode' ),
                    formatversion: 2,
                    errorformat: 'html',
                    errorlang: mw.config.get( 'wgUserLanguage' ),
                    errorsuselocal: true
                },
                data
            );

            const action = data.action;

            const request = api.postWithToken( 'csrf', data, {
                contentType: 'multipart/form-data',
                trackEditAttemptStepSessionId: true
            } );

            return request.then(
                ( response, jqxhr ) => {
                    const responseData = response[ action ];

                    // Log data about the request if eventName was set
                    if ( options.track && options.eventName ) {
                        const eventData = {
                            bytes: require( 'mediawiki.String' ).byteLength( jqxhr.responseText ),
                            duration: options.now() - start
                        };
                        const fullEventName = 'performance.system.' + options.eventName +
                            ( responseData.cachekey ? '.withCacheKey' : '.withoutCacheKey' );
                        options.track( fullEventName, eventData );
                    }

                    let error;
                    if ( !responseData ) {
                        error = {
                            code: 'invalidresponse',
                            html: mw.message( 'api-clientside-error-invalidresponse' ).parse()
                        };
                    } else if ( responseData.result !== 'success' ) {
                        // This should only happen when saving an edit and getting a captcha from ConfirmEdit
                        // extension (`data.result === 'error'`). It's a silly special case...
                        return $.Deferred().reject( 'no-error-no-success', response ).promise();
                    } else {
                        // paction specific errors
                        switch ( responseData.paction ) {
                            case 'save':
                            case 'serialize':
                                if ( typeof responseData.content !== 'string' ) {
                                    error = {
                                        code: 'invalidcontent',
                                        html: mw.message( 'api-clientside-error-invalidresponse' ).parse()
                                    };
                                }
                                break;
                            case 'diff':
                                if ( typeof responseData.diff !== 'string' ) {
                                    error = {
                                        code: 'invalidcontent',
                                        html: mw.message( 'api-clientside-error-invalidresponse' ).parse()
                                    };
                                }
                                break;
                        }
                    }

                    if ( error ) {
                        // Use the same format as API errors
                        return $.Deferred().reject( error.code, { errors: [ error ] } ).promise();
                    }
                    return responseData;
                },
                ( code, response ) => {
                    const responseText = OO.getProp( response, 'xhr', 'responseText' );

                    if ( responseText && options.track && options.eventName ) {
                        const eventData = {
                            bytes: require( 'mediawiki.String' ).byteLength( responseText ),
                            duration: options.now() - start
                        };
                        let fullEventName;
                        if ( code === 'badcachekey' ) {
                            fullEventName = 'performance.system.' + options.eventName + '.badCacheKey';
                        } else {
                            fullEventName = 'performance.system.' + options.eventName + '.withoutCacheKey';
                        }
                        options.track( fullEventName, eventData );
                    }
                    return $.Deferred().reject( code, response ).promise();
                }
            );
        }
    };
}() );