wikimedia/mediawiki-core

View on GitHub
resources/src/mediawiki.user.js

Summary

Maintainability
A
0 mins
Test Coverage
/**
 * User library provided by 'mediawiki.user' ResourceLoader module.
 *
 * @namespace mw.user
 */
( function () {
    var userInfoPromise, tempUserNamePromise, pageviewRandomId, sessionId;
    var CLIENTPREF_COOKIE_NAME = 'mwclientpreferences';
    var CLIENTPREF_SUFFIX = '-clientpref-';
    var CLIENTPREF_DELIMITER = ',';

    /**
     * Get the current user's groups or rights
     *
     * @private
     * @return {jQuery.Promise}
     */
    function getUserInfo() {
        if ( !userInfoPromise ) {
            userInfoPromise = new mw.Api().getUserInfo();
        }
        return userInfoPromise;
    }

    /**
     * Save the feature value to the client preferences cookie.
     *
     * @private
     * @param {string} feature
     * @param {string} value
     */
    function saveClientPrefs( feature, value ) {
        var existingCookie = mw.cookie.get( CLIENTPREF_COOKIE_NAME ) || '';
        var data = {};
        existingCookie.split( CLIENTPREF_DELIMITER ).forEach( function ( keyValuePair ) {
            var m = keyValuePair.match( /^([\w-]+)-clientpref-(\w+)$/ );
            if ( m ) {
                data[ m[ 1 ] ] = m[ 2 ];
            }
        } );
        data[ feature ] = value;

        var newCookie = Object.keys( data ).map( function ( key ) {
            return key + CLIENTPREF_SUFFIX + data[ key ];
        } ).join( CLIENTPREF_DELIMITER );
        mw.cookie.set( CLIENTPREF_COOKIE_NAME, newCookie );
    }

    /**
     * Check if the feature name is composed of valid characters.
     *
     * A valid feature name may contain letters, numbers, and "-" characters.
     *
     * @private
     * @param {string} value
     * @return {boolean}
     */
    function isValidFeatureName( value ) {
        return value.match( /^[a-zA-Z0-9-]+$/ ) !== null;
    }

    /**
     * Check if the value is composed of valid characters.
     *
     * @private
     * @param {string} value
     * @return {boolean}
     */
    function isValidFeatureValue( value ) {
        return value.match( /^[a-zA-Z0-9]+$/ ) !== null;
    }

    // mw.user with the properties options and tokens gets defined in mediawiki.base.js.
    Object.assign( mw.user, /** @lends mw.user */{

        /**
         * Generate a random user session ID.
         *
         * This information would potentially be stored in a cookie to identify a user during a
         * session or series of sessions. Its uniqueness should not be depended on unless the
         * browser supports the crypto API.
         *
         * Known problems with `Math.random()`:
         * Using the `Math.random` function we have seen sets
         * with 1% of non uniques among 200,000 values with Safari providing most of these.
         * Given the prevalence of Safari in mobile the percentage of duplicates in
         * mobile usages of this code is probably higher.
         *
         * Rationale:
         * We need about 80 bits to make sure that probability of collision
         * on 155 billion  is <= 1%
         *
         * See {@link https://en.wikipedia.org/wiki/Birthday_attack#Mathematics}
         *
         * `n(p;H) = n(0.01,2^80)= sqrt (2 * 2^80 * ln(1/(1-0.01)))`
         *
         * @return {string} 80 bit integer (20 characters) in hex format, padded
         */
        generateRandomSessionId: function () {
            let rnds;

            // We first attempt to generate a set of random values using the WebCrypto API's
            // getRandomValues method. If the WebCrypto API is not supported, the Uint16Array
            // type does not exist, or getRandomValues fails (T263041), an exception will be
            // thrown, which we'll catch and fall back to using Math.random.
            try {
                // Initialize a typed array containing 5 0-initialized 16-bit integers.
                // Note that Uint16Array is array-like but does not implement Array.

                rnds = new Uint16Array( 5 );
                // Overwrite the array elements with cryptographically strong random values.
                // https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues
                // NOTE: this operation can fail internally (T263041), so the try-catch block
                // must be preserved even after WebCrypto is supported in all modern (Grade A)
                // browsers.
                crypto.getRandomValues( rnds );
            } catch ( e ) {
                rnds = new Array( 5 );
                // 0x10000 is 2^16 so the operation below will return a number
                // between 2^16 and zero
                for ( let i = 0; i < 5; i++ ) {
                    rnds[ i ] = Math.floor( Math.random() * 0x10000 );
                }
            }

            // Convert the 5 16bit-numbers into 20 characters (4 hex per 16 bits).
            // Concatenation of two random integers with entropy n and m
            // returns a string with entropy n+m if those strings are independent.
            // Tested that below code is faster than array + loop + join.
            return ( rnds[ 0 ] + 0x10000 ).toString( 16 ).slice( 1 ) +
                ( rnds[ 1 ] + 0x10000 ).toString( 16 ).slice( 1 ) +
                ( rnds[ 2 ] + 0x10000 ).toString( 16 ).slice( 1 ) +
                ( rnds[ 3 ] + 0x10000 ).toString( 16 ).slice( 1 ) +
                ( rnds[ 4 ] + 0x10000 ).toString( 16 ).slice( 1 );
        },

        /**
         * A sticky generateRandomSessionId for the current JS execution context,
         * cached within this class (also known as a page view token).
         *
         * @since 1.32
         * @return {string} 80 bit integer in hex format, padded
         */
        getPageviewToken: function () {
            if ( !pageviewRandomId ) {
                pageviewRandomId = mw.user.generateRandomSessionId();
            }

            return pageviewRandomId;
        },

        /**
         * Get the current user's database id.
         *
         * Not to be confused with {@link mw.user#id id}.
         *
         * @return {number} Current user's id, or 0 if user is anonymous
         */
        getId: function () {
            return mw.config.get( 'wgUserId' ) || 0;
        },

        /**
         * Check whether the user is a normal non-temporary registered user.
         *
         * @return {boolean}
         */
        isNamed: function () {
            return !mw.user.isAnon() && !mw.user.isTemp();
        },

        /**
         * Check whether the user is an autocreated temporary user.
         *
         * @return {boolean}
         */
        isTemp: function () {
            return mw.config.get( 'wgUserIsTemp' ) || false;
        },

        /**
         * Get the current user's name.
         *
         * @return {string|null} User name string or null if user is anonymous
         */
        getName: function () {
            return mw.config.get( 'wgUserName' );
        },

        /**
         * Acquire a temporary user username and stash it in the current session, if temp account creation
         * is enabled and the current user is logged out. If a name has already been stashed, returns the
         * same name.
         *
         * If the user later performs an action that results in temp account creation, the stashed username
         * will be used for their account. It may also be used in previews. However, the account is not
         * created yet, and the name is not visible to other users.
         *
         * @return {jQuery.Promise} Promise resolved with the username if we succeeded,
         *   or resolved with `null` if we failed
         */
        acquireTempUserName: function () {
            if ( tempUserNamePromise !== undefined ) {
                // Return the existing promise if we already tried. Do not retry even if we failed.
                return tempUserNamePromise;
            }

            if ( mw.config.get( 'wgUserId' ) ) {
                // User is logged in (or has a temporary account), nothing to do
                tempUserNamePromise = $.Deferred().resolve( null );
            } else if ( mw.config.get( 'wgTempUserName' ) ) {
                // Temporary user username already acquired
                tempUserNamePromise = $.Deferred().resolve( mw.config.get( 'wgTempUserName' ) );
            } else {
                var api = new mw.Api();
                tempUserNamePromise = api.post( { action: 'acquiretempusername' } ).then( function ( resp ) {
                    mw.config.set( 'wgTempUserName', resp.acquiretempusername );
                    return resp.acquiretempusername;
                } ).catch( function () {
                    // Ignore failures. The temp name should not be necessary for anything to work.
                    return null;
                } );
            }

            return tempUserNamePromise;
        },

        /**
         * Get date user registered, if available.
         *
         * @return {boolean|null|Date} False for anonymous users, null if data is
         *  unavailable, or Date for when the user registered.
         */
        getRegistration: function () {
            var registration;
            if ( mw.user.isAnon() ) {
                return false;
            }
            registration = mw.config.get( 'wgUserRegistration' );
            // Registration may be unavailable if the user signed up before MediaWiki
            // began tracking this.
            return !registration ? null : new Date( registration );
        },

        /**
         * Get date user first registered, if available.
         *
         * @return {boolean|null|Date} False for anonymous users, null if data is
         *  unavailable, or Date for when the user registered. For temporary users
         *  that is when their temporary account was created.
         */
        getFirstRegistration: function () {
            if ( mw.user.isAnon() ) {
                return false;
            }
            var registration = mw.config.get( 'wgUserFirstRegistration' );
            // Registration may be unavailable if the user signed up before MediaWiki
            // began tracking this.
            return registration ? new Date( registration ) : null;
        },

        /**
         * Check whether the current user is anonymous.
         *
         * @return {boolean}
         */
        isAnon: function () {
            return mw.user.getName() === null;
        },

        /**
         * Retrieve a random ID, generating it if needed.
         *
         * This ID is shared across windows, tabs, and page views. It is persisted
         * for the duration of one browser session (until the browser app is closed),
         * unless the user evokes a "restore previous session" feature that some browsers have.
         *
         * **Note:** Server-side code must never interpret or modify this value.
         *
         * @return {string} Random session ID (20 hex characters)
         */
        sessionId: function () {
            if ( sessionId === undefined ) {
                sessionId = mw.cookie.get( 'mwuser-sessionId' );
                // Validate that the value is 20 hex characters, as it is user-controlled,
                // and we also used different formats in the past (T283881)
                if ( sessionId === null || !/^[0-9a-f]{20}$/.test( sessionId ) ) {
                    sessionId = mw.user.generateRandomSessionId();
                    // Setting the `expires` field to `null` means that the cookie should
                    // persist (shared across windows and tabs) until the browser is closed.
                    mw.cookie.set( 'mwuser-sessionId', sessionId, { expires: null } );
                }
            }
            return sessionId;
        },

        /**
         * Get the current user's name or the session ID.
         *
         * Not to be confused with {@link mw.user#getId getId}.
         *
         * @return {string} User name or random session ID
         */
        id: function () {
            return mw.user.getName() || mw.user.sessionId();
        },

        /**
         * Get the current user's groups.
         *
         * @param {Function} [callback]
         * @return {jQuery.Promise}
         */
        getGroups: function ( callback ) {
            var userGroups = mw.config.get( 'wgUserGroups', [] );

            // Uses promise for backwards compatibility
            return $.Deferred().resolve( userGroups ).then( callback );
        },

        /**
         * Get the current user's rights.
         *
         * @param {Function} [callback]
         * @return {jQuery.Promise}
         */
        getRights: function ( callback ) {
            return getUserInfo().then(
                function ( userInfo ) {
                    return userInfo.rights;
                },
                function () {
                    return [];
                }
            ).then( callback );
        },

        /**
         * Manage client preferences.
         *
         * For skins that enable the `clientPrefEnabled` option (see Skin class in PHP),
         * this feature allows you to store preferences in the browser session that will
         * switch one or more the classes on the HTML document.
         *
         * This is only supported for unregistered users. For registered users, skins
         * and extensions must use user preferences (e.g. hidden or API-only options)
         * and swap class names server-side through the Skin interface.
         *
         * This feature is limited to page views by unregistered users. For logged-in requests,
         * store preferences in the database instead, via UserOptionsManager or
         * {@link mw.Api#saveOption} (may be hidden or API-only to exclude from Special:Preferences),
         * and then include the desired classes directly in Skin::getHtmlElementAttributes.
         *
         * Classes toggled by this feature must be named as `<feature>-clientpref-<value>`,
         * where `value` contains only alphanumerical characters (a-z, A-Z, and 0-9), and `feature`
         * can also include hyphens.
         *
         * @namespace mw.user.clientPrefs
         */
        clientPrefs: {
            /**
             * Change the class on the HTML document element, and save the value in a cookie.
             *
             * @memberof mw.user.clientPrefs
             * @param {string} feature
             * @param {string} value
             * @return {boolean} True if feature was stored successfully, false if the value
             *   uses a forbidden character or the feature is not recognised
             *   e.g. a matching class was not defined on the HTML document element.
             */
            set: function ( feature, value ) {
                if ( mw.user.isNamed() ) {
                    // Avoid storing an unused cookie and returning true when the setting
                    // wouldn't actually be applied.
                    // Encourage future-proof and server-first implementations.
                    // Encourage feature parity for logged-in users.
                    throw new Error( 'clientPrefs are for unregistered users only' );
                }
                if ( !isValidFeatureName( feature ) || !isValidFeatureValue( value ) ) {
                    return false;
                }
                var currentValue = mw.user.clientPrefs.get( feature );
                // the feature is not recognized
                if ( !currentValue ) {
                    return false;
                }
                var oldFeatureClass = feature + CLIENTPREF_SUFFIX + currentValue;
                var newFeatureClass = feature + CLIENTPREF_SUFFIX + value;
                // The following classes are removed here:
                // * feature-name-clientpref-<old-feature-value>
                // * e.g. vector-font-size--clientpref-small
                document.documentElement.classList.remove( oldFeatureClass );
                // The following classes are added here:
                // * feature-name-clientpref-<feature-value>
                // * e.g. vector-font-size--clientpref-xlarge
                document.documentElement.classList.add( newFeatureClass );
                saveClientPrefs( feature, value );
                return true;
            },

            /**
             * Retrieve the current value of the feature from the HTML document element.
             *
             * @memberof mw.user.clientPrefs
             * @param {string} feature
             * @return {string|boolean} returns boolean if the feature is not recognized
             *  returns string if a feature was found.
             */
            get: function ( feature ) {
                var featurePrefix = feature + CLIENTPREF_SUFFIX;
                var docClass = document.documentElement.className;
                // eslint-disable-next-line security/detect-non-literal-regexp
                var featureRegEx = new RegExp(
                    '(^| )' + mw.util.escapeRegExp( featurePrefix ) + '([a-zA-Z0-9]+)( |$)'
                );
                var match = docClass.match( featureRegEx );

                // check no further matches if we replaced this occurance.
                var isAmbiguous = docClass.replace( featureRegEx, '$1$3' ).match( featureRegEx ) !== null;
                return !isAmbiguous && match ? match[ 2 ] : false;
            }
        }
    } );

}() );