Make WordPress Core

Changeset 38810


Ignore:
Timestamp:
10/18/2016 08:04:36 PM (10 years ago)
Author:
westonruter
Message:

Customize: Implement customized state persistence with changesets.

Includes infrastructure developed in the Customize Snapshots feature plugin.

See https://make.wordpress.org/core/2016/10/12/customize-changesets-technical-design-decisions/

Props westonruter, valendesigns, utkarshpatel, stubgo, lgedeon, ocean90, ryankienstra, mihai2u, dlh, aaroncampbell, jonathanbardo, jorbin.
See #28721.
See #31089.
Fixes #30937.
Fixes #31517.
Fixes #30028.
Fixes #23225.
Fixes #34142.
Fixes #36485.

Location:
trunk
Files:
1 added
31 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-admin/customize.php

    r38672 r38810  
    2121}
    2222
     23/**
     24 * @global WP_Scripts           $wp_scripts
     25 * @global WP_Customize_Manager $wp_customize
     26 */
     27global $wp_scripts, $wp_customize;
     28
     29if ( $wp_customize->changeset_post_id() ) {
     30        if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $wp_customize->changeset_post_id() ) ) {
     31                wp_die(
     32                        '<h1>' . __( 'Cheatin&#8217; uh?' ) . '</h1>' .
     33                        '<p>' . __( 'Sorry, you are not allowed to edit this changeset.' ) . '</p>',
     34                        403
     35                );
     36        }
     37        if ( in_array( get_post_status( $wp_customize->changeset_post_id() ), array( 'publish', 'trash' ), true ) ) {
     38                wp_die(
     39                        '<h1>' . __( 'Cheatin&#8217; uh?' ) . '</h1>' .
     40                        '<p>' . __( 'This changeset has already been published and cannot be further modified.' ) . '</p>' .
     41                        '<p><a href="' . esc_url( remove_query_arg( 'changeset_uuid' ) ) . '">' . __( 'Customize New Changes' ) . '</a></p>',
     42                        403
     43                );
     44        }
     45}
     46
     47
    2348wp_reset_vars( array( 'url', 'return', 'autofocus' ) );
    2449if ( ! empty( $url ) ) {
     
    3156        $wp_customize->set_autofocus( wp_unslash( $autofocus ) );
    3257}
    33 
    34 /**
    35  * @global WP_Scripts           $wp_scripts
    36  * @global WP_Customize_Manager $wp_customize
    37  */
    38 global $wp_scripts, $wp_customize;
    3958
    4059$registered = $wp_scripts->registered;
     
    116135                        <?php
    117136                        $save_text = $wp_customize->is_theme_active() ? __( 'Save &amp; Publish' ) : __( 'Save &amp; Activate' );
    118                         submit_button( $save_text, 'primary save', 'save', false );
     137                        $save_attrs = array();
     138                        if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->publish_posts ) ) {
     139                                $save_attrs['style'] = 'display: none';
     140                        }
     141                        submit_button( $save_text, 'primary save', 'save', false, $save_attrs );
    119142                        ?>
    120143                        <span class="spinner"></span>
  • trunk/src/wp-admin/js/customize-controls.js

    r38742 r38810  
    2323        api.Setting = api.Value.extend({
    2424                initialize: function( id, value, options ) {
    25                         api.Value.prototype.initialize.call( this, value, options );
    26 
    27                         this.id = id;
    28                         this.transport = this.transport || 'refresh';
    29                         this._dirty = options.dirty || false;
    30                         this.notifications = new api.Values({ defaultConstructor: api.Notification });
     25                        var setting = this;
     26                        api.Value.prototype.initialize.call( setting, value, options );
     27
     28                        setting.id = id;
     29                        setting.transport = setting.transport || 'refresh';
     30                        setting._dirty = options.dirty || false;
     31                        setting.notifications = new api.Values({ defaultConstructor: api.Notification });
    3132
    3233                        // Whenever the setting's value changes, refresh the preview.
    33                         this.bind( this.preview );
     34                        setting.bind( setting.preview );
    3435                },
    3536
    3637                /**
    3738                 * Refresh the preview, respective of the setting's refresh policy.
     39                 *
     40                 * If the preview hasn't sent a keep-alive message and is likely
     41                 * disconnected by having navigated to a non-allowed URL, then the
     42                 * refresh transport will be forced when postMessage is the transport.
     43                 * Note that postMessage does not throw an error when the recipient window
     44                 * fails to match the origin window, so using try/catch around the
     45                 * previewer.send() call to then fallback to refresh will not work.
     46                 *
     47                 * @since 3.4.0
    3848                 */
    3949                preview: function() {
    40                         switch ( this.transport ) {
    41                                 case 'refresh':
    42                                         return this.previewer.refresh();
    43                                 case 'postMessage':
    44                                         return this.previewer.send( 'setting', [ this.id, this() ] );
     50                        var setting = this, transport;
     51                        transport = setting.transport;
     52
     53                        if ( 'postMessage' === transport && ! api.state( 'previewerAlive' ).get() ) {
     54                                transport = 'refresh';
     55                        }
     56
     57                        if ( 'postMessage' === transport ) {
     58                                return setting.previewer.send( 'setting', [ setting.id, setting() ] );
     59                        } else if ( 'refresh' === transport ) {
     60                                return setting.previewer.refresh();
    4561                        }
    4662                },
     
    6682
    6783        /**
    68          * Utility function namespace
     84         * Current change count.
     85         *
     86         * @since 4.7.0
     87         * @type {number}
     88         * @protected
    6989         */
    70         api.utils = {};
     90        api._latestRevision = 0;
     91
     92        /**
     93         * Last revision that was saved.
     94         *
     95         * @since 4.7.0
     96         * @type {number}
     97         * @protected
     98         */
     99        api._lastSavedRevision = 0;
     100
     101        /**
     102         * Latest revisions associated with the updated setting.
     103         *
     104         * @since 4.7.0
     105         * @type {object}
     106         * @protected
     107         */
     108        api._latestSettingRevisions = {};
     109
     110        /*
     111         * Keep track of the revision associated with each updated setting so that
     112         * requestChangesetUpdate knows which dirty settings to include. Also, once
     113         * ready is triggered and all initial settings have been added, increment
     114         * revision for each newly-created initially-dirty setting so that it will
     115         * also be included in changeset update requests.
     116         */
     117        api.bind( 'change', function incrementChangedSettingRevision( setting ) {
     118                api._latestRevision += 1;
     119                api._latestSettingRevisions[ setting.id ] = api._latestRevision;
     120        } );
     121        api.bind( 'ready', function() {
     122                api.bind( 'add', function incrementCreatedSettingRevision( setting ) {
     123                        if ( setting._dirty ) {
     124                                api._latestRevision += 1;
     125                                api._latestSettingRevisions[ setting.id ] = api._latestRevision;
     126                        }
     127                } );
     128        } );
     129
     130        /**
     131         * Get the dirty setting values.
     132         *
     133         * @param {object} [options] Options.
     134         * @param {boolean} [options.unsaved=false] Whether only values not saved yet into a changeset will be returned (differential changes).
     135         * @returns {object} Dirty setting values.
     136         */
     137        api.dirtyValues = function dirtyValues( options ) {
     138                var values = {};
     139                api.each( function( setting ) {
     140                        var settingRevision;
     141
     142                        if ( ! setting._dirty ) {
     143                                return;
     144                        }
     145
     146                        settingRevision = api._latestSettingRevisions[ setting.id ];
     147
     148                        // Skip including settings that have already been included in the changeset, if only requesting unsaved.
     149                        if ( ( options && options.unsaved ) && ( _.isUndefined( settingRevision ) || settingRevision <= api._lastSavedRevision ) ) {
     150                                return;
     151                        }
     152
     153                        values[ setting.id ] = setting.get();
     154                } );
     155                return values;
     156        };
     157
     158        /**
     159         * Request updates to the changeset.
     160         *
     161         * @param {object} [changes] Mapping of setting IDs to setting params each normally including a value property, or mapping to null.
     162         *                           If not provided, then the changes will still be obtained from unsaved dirty settings.
     163         * @returns {jQuery.Promise}
     164         */
     165        api.requestChangesetUpdate = function requestChangesetUpdate( changes ) {
     166                var deferred, request, submittedChanges = {}, data;
     167                deferred = new $.Deferred();
     168
     169                if ( changes ) {
     170                        _.extend( submittedChanges, changes );
     171                }
     172
     173                // Ensure all revised settings (changes pending save) are also included, but not if marked for deletion in changes.
     174                _.each( api.dirtyValues( { unsaved: true } ), function( dirtyValue, settingId ) {
     175                        if ( ! changes || null !== changes[ settingId ] ) {
     176                                submittedChanges[ settingId ] = _.extend(
     177                                        {},
     178                                        submittedChanges[ settingId ] || {},
     179                                        { value: dirtyValue }
     180                                );
     181                        }
     182                } );
     183
     184                // Short-circuit when there are no pending changes.
     185                if ( _.isEmpty( submittedChanges ) ) {
     186                        deferred.resolve( {} );
     187                        return deferred.promise();
     188                }
     189
     190                // Make sure that publishing a changeset waits for all changeset update requests to complete.
     191                api.state( 'processing' ).set( api.state( 'processing' ).get() + 1 );
     192                deferred.always( function() {
     193                        api.state( 'processing' ).set( api.state( 'processing' ).get() - 1 );
     194                } );
     195
     196                // Allow plugins to attach additional params to the settings.
     197                api.trigger( 'changeset-save', submittedChanges );
     198
     199                // Ensure that if any plugins add data to save requests by extending query() that they get included here.
     200                data = api.previewer.query( { excludeCustomizedSaved: true } );
     201                delete data.customized; // Being sent in customize_changeset_data instead.
     202                _.extend( data, {
     203                        nonce: api.settings.nonce.save,
     204                        customize_theme: api.settings.theme.stylesheet,
     205                        customize_changeset_data: JSON.stringify( submittedChanges )
     206                } );
     207
     208                request = wp.ajax.post( 'customize_save', data );
     209
     210                request.done( function requestChangesetUpdateDone( data ) {
     211                        var savedChangesetValues = {};
     212
     213                        // Ensure that all settings updated subsequently will be included in the next changeset update request.
     214                        api._lastSavedRevision = Math.max( api._latestRevision, api._lastSavedRevision );
     215
     216                        api.state( 'changesetStatus' ).set( data.changeset_status );
     217                        deferred.resolve( data );
     218                        api.trigger( 'changeset-saved', data );
     219
     220                        if ( data.setting_validities ) {
     221                                _.each( data.setting_validities, function( validity, settingId ) {
     222                                        if ( true === validity && _.isObject( submittedChanges[ settingId ] ) && ! _.isUndefined( submittedChanges[ settingId ].value ) ) {
     223                                                savedChangesetValues[ settingId ] = submittedChanges[ settingId ].value;
     224                                        }
     225                                } );
     226                        }
     227
     228                        api.previewer.send( 'changeset-saved', _.extend( {}, data, { saved_changeset_values: savedChangesetValues } ) );
     229                } );
     230                request.fail( function requestChangesetUpdateFail( data ) {
     231                        deferred.reject( data );
     232                        api.trigger( 'changeset-error', data );
     233                } );
     234                request.always( function( data ) {
     235                        if ( data.setting_validities ) {
     236                                api._handleSettingValidities( {
     237                                        settingValidities: data.setting_validities
     238                                } );
     239                        }
     240                } );
     241
     242                return deferred.promise();
     243        };
    71244
    72245        /**
     
    12171390
    12181391                /**
     1392                 * Load theme preview.
     1393                 *
     1394                 * @since 4.7.0
     1395                 *
     1396                 * @param {string} themeId Theme ID.
     1397                 * @returns {jQuery.promise} Promise.
     1398                 */
     1399                loadThemePreview: function( themeId ) {
     1400                        var deferred = $.Deferred(), onceProcessingComplete, overlay, urlParser;
     1401
     1402                        urlParser = document.createElement( 'a' );
     1403                        urlParser.href = location.href;
     1404                        urlParser.search = $.param( _.extend(
     1405                                api.utils.parseQueryString( urlParser.search.substr( 1 ) ),
     1406                                {
     1407                                        theme: themeId,
     1408                                        changeset_uuid: api.settings.changeset.uuid
     1409                                }
     1410                        ) );
     1411
     1412                        overlay = $( '.wp-full-overlay' );
     1413                        overlay.addClass( 'customize-loading' );
     1414
     1415                        onceProcessingComplete = function() {
     1416                                var request;
     1417                                if ( api.state( 'processing' ).get() > 0 ) {
     1418                                        return;
     1419                                }
     1420
     1421                                api.state( 'processing' ).unbind( onceProcessingComplete );
     1422
     1423                                request = api.requestChangesetUpdate();
     1424                                request.done( function() {
     1425                                        $( window ).off( 'beforeunload.customize-confirm' );
     1426                                        window.location.href = urlParser.href;
     1427                                } );
     1428                                request.fail( function() {
     1429                                        overlay.removeClass( 'customize-loading' );
     1430                                } );
     1431                        };
     1432
     1433                        if ( 0 === api.state( 'processing' ).get() ) {
     1434                                onceProcessingComplete();
     1435                        } else {
     1436                                api.state( 'processing' ).bind( onceProcessingComplete );
     1437                        }
     1438
     1439                        return deferred.promise();
     1440                },
     1441
     1442                /**
    12191443                 * Render & show the theme details for a given theme model.
    12201444                 *
     
    12241448                 */
    12251449                showDetails: function ( theme, callback ) {
    1226                         var section = this;
     1450                        var section = this, link;
    12271451                        callback = callback || function(){};
    12281452                        section.currentTheme = theme.id;
     
    12331457                        section.containFocus( section.overlay );
    12341458                        section.updateLimits();
     1459
     1460                        link = section.overlay.find( '.inactive-theme > a' );
     1461
     1462                        link.on( 'click', function( event ) {
     1463                                event.preventDefault();
     1464
     1465                                // Short-circuit if request is currently being made.
     1466                                if ( link.hasClass( 'disabled' ) ) {
     1467                                        return;
     1468                                }
     1469                                link.addClass( 'disabled' );
     1470
     1471                                section.loadThemePreview( theme.id ).fail( function() {
     1472                                        link.removeClass( 'disabled' );
     1473                                } );
     1474                        } );
    12351475                        callback();
    12361476                },
     
    22282468                                nonce: _wpCustomizeBackground.nonces.add,
    22292469                                wp_customize: 'on',
    2230                                 theme: api.settings.theme.stylesheet,
     2470                                customize_theme: api.settings.theme.stylesheet,
    22312471                                attachment_id: this.params.attachment.id
    22322472                        } );
     
    25932833                        // Ensure custom-header-crop Ajax requests bootstrap the Customizer to activate the previewed theme.
    25942834                        wp.media.controller.Cropper.prototype.defaults.doCropArgs.wp_customize = 'on';
    2595                         wp.media.controller.Cropper.prototype.defaults.doCropArgs.theme = api.settings.theme.stylesheet;
     2835                        wp.media.controller.Cropper.prototype.defaults.doCropArgs.customize_theme = api.settings.theme.stylesheet;
    25962836                },
    25972837
     
    28843124                                }
    28853125
    2886                                 var previewUrl = $( this ).data( 'previewUrl' );
    2887 
    2888                                 $( '.wp-full-overlay' ).addClass( 'customize-loading' );
    2889 
    2890                                 window.parent.location = previewUrl;
     3126                                api.section( control.section() ).loadThemePreview( control.params.theme.id );
    28913127                        });
    28923128
     
    29493185         */
    29503186        api.PreviewFrame = api.Messenger.extend({
    2951                 sensitivity: 2000,
     3187                sensitivity: null, // Will get set to api.settings.timeouts.previewFrameSensitivity.
    29523188
    29533189                /**
     
    29553191                 *
    29563192                 * @param {object} params.container
    2957                  * @param {object} params.signature
    29583193                 * @param {object} params.previewUrl
    29593194                 * @param {object} params.query
     
    29703205
    29713206                        this.container = params.container;
    2972                         this.signature = params.signature;
    29733207
    29743208                        $.extend( params, { channel: api.PreviewFrame.uuid() });
     
    29903224                 */
    29913225                run: function( deferred ) {
    2992                         var self  = this,
     3226                        var previewFrame = this,
    29933227                                loaded = false,
    2994                                 ready  = false;
    2995 
    2996                         if ( this._ready ) {
    2997                                 this.unbind( 'ready', this._ready );
    2998                         }
    2999 
    3000                         this._ready = function() {
     3228                                ready = false,
     3229                                readyData = null,
     3230                                hasPendingChangesetUpdate = '{}' !== previewFrame.query.customized,
     3231                                urlParser,
     3232                                params,
     3233                                form;
     3234
     3235                        if ( previewFrame._ready ) {
     3236                                previewFrame.unbind( 'ready', previewFrame._ready );
     3237                        }
     3238
     3239                        previewFrame._ready = function( data ) {
    30013240                                ready = true;
    3002 
    3003                                 if ( loaded ) {
    3004                                         deferred.resolveWith( self );
    3005                                 }
    3006                         };
    3007 
    3008                         this.bind( 'ready', this._ready );
    3009 
    3010                         this.bind( 'ready', function ( data ) {
    3011 
    3012                                 this.container.addClass( 'iframe-ready' );
    3013 
     3241                                readyData = data;
     3242                                previewFrame.container.addClass( 'iframe-ready' );
    30143243                                if ( ! data ) {
    30153244                                        return;
    30163245                                }
    30173246
    3018                                 /*
    3019                                  * Walk over all panels, sections, and controls and set their
    3020                                  * respective active states to true if the preview explicitly
    3021                                  * indicates as such.
    3022                                  */
    3023                                 var constructs = {
    3024                                         panel: data.activePanels,
    3025                                         section: data.activeSections,
    3026                                         control: data.activeControls
    3027                                 };
    3028                                 _( constructs ).each( function ( activeConstructs, type ) {
    3029                                         api[ type ].each( function ( construct, id ) {
    3030                                                 var isDynamicallyCreated = _.isUndefined( api.settings[ type + 's' ][ id ] );
    3031 
    3032                                                 /*
    3033                                                  * If the construct was created statically in PHP (not dynamically in JS)
    3034                                                  * then consider a missing (undefined) value in the activeConstructs to
    3035                                                  * mean it should be deactivated (since it is gone). But if it is
    3036                                                  * dynamically created then only toggle activation if the value is defined,
    3037                                                  * as this means that the construct was also then correspondingly
    3038                                                  * created statically in PHP and the active callback is available.
    3039                                                  * Otherwise, dynamically-created constructs should normally have
    3040                                                  * their active states toggled in JS rather than from PHP.
    3041                                                  */
    3042                                                 if ( ! isDynamicallyCreated || ! _.isUndefined( activeConstructs[ id ] ) ) {
    3043                                                         if ( activeConstructs[ id ] ) {
    3044                                                                 construct.activate();
    3045                                                         } else {
    3046                                                                 construct.deactivate();
    3047                                                         }
    3048                                                 }
    3049                                         } );
     3247                                if ( loaded ) {
     3248                                        deferred.resolveWith( previewFrame, [ data ] );
     3249                                }
     3250                        };
     3251
     3252                        previewFrame.bind( 'ready', previewFrame._ready );
     3253
     3254                        urlParser = document.createElement( 'a' );
     3255                        urlParser.href = previewFrame.previewUrl();
     3256
     3257                        params = _.extend(
     3258                                api.utils.parseQueryString( urlParser.search.substr( 1 ) ),
     3259                                {
     3260                                        customize_changeset_uuid: previewFrame.query.customize_changeset_uuid,
     3261                                        customize_theme: previewFrame.query.customize_theme,
     3262                                        customize_messenger_channel: previewFrame.query.customize_messenger_channel
     3263                                }
     3264                        );
     3265
     3266                        urlParser.search = $.param( params );
     3267                        previewFrame.iframe = $( '<iframe />', {
     3268                                title: api.l10n.previewIframeTitle,
     3269                                name: 'customize-' + previewFrame.channel()
     3270                        } );
     3271                        previewFrame.iframe.attr( 'onmousewheel', '' ); // Workaround for Safari bug. See WP Trac #38149.
     3272
     3273                        if ( ! hasPendingChangesetUpdate ) {
     3274                                previewFrame.iframe.attr( 'src', urlParser.href );
     3275                        } else {
     3276                                previewFrame.iframe.attr( 'data-src', urlParser.href ); // For debugging purposes.
     3277                        }
     3278
     3279                        previewFrame.iframe.appendTo( previewFrame.container );
     3280                        previewFrame.targetWindow( previewFrame.iframe[0].contentWindow );
     3281
     3282                        /*
     3283                         * Submit customized data in POST request to preview frame window since
     3284                         * there are setting value changes not yet written to changeset.
     3285                         */
     3286                        if ( hasPendingChangesetUpdate ) {
     3287                                form = $( '<form>', {
     3288                                        action: urlParser.href,
     3289                                        target: previewFrame.iframe.attr( 'name' ),
     3290                                        method: 'post',
     3291                                        hidden: 'hidden'
    30503292                                } );
    3051 
    3052                                 if ( data.settingValidities ) {
    3053                                         api._handleSettingValidities( {
    3054                                                 settingValidities: data.settingValidities,
    3055                                                 focusInvalidControl: false
    3056                                         } );
    3057                                 }
     3293                                form.append( $( '<input>', {
     3294                                        type: 'hidden',
     3295                                        name: '_method',
     3296                                        value: 'GET'
     3297                                } ) );
     3298                                _.each( previewFrame.query, function( value, key ) {
     3299                                        form.append( $( '<input>', {
     3300                                                type: 'hidden',
     3301                                                name: key,
     3302                                                value: value
     3303                                        } ) );
     3304                                } );
     3305                                previewFrame.container.append( form );
     3306                                form.submit();
     3307                                form.remove(); // No need to keep the form around after submitted.
     3308                        }
     3309
     3310                        previewFrame.bind( 'iframe-loading-error', function( error ) {
     3311                                previewFrame.iframe.remove();
     3312
     3313                                // Check if the user is not logged in.
     3314                                if ( 0 === error ) {
     3315                                        previewFrame.login( deferred );
     3316                                        return;
     3317                                }
     3318
     3319                                // Check for cheaters.
     3320                                if ( -1 === error ) {
     3321                                        deferred.rejectWith( previewFrame, [ 'cheatin' ] );
     3322                                        return;
     3323                                }
     3324
     3325                                deferred.rejectWith( previewFrame, [ 'request failure' ] );
    30583326                        } );
    30593327
    3060                         this.request = $.ajax( this.previewUrl(), {
    3061                                 type: 'POST',
    3062                                 data: this.query,
    3063                                 xhrFields: {
    3064                                         withCredentials: true
    3065                                 }
    3066                         } );
    3067 
    3068                         this.request.fail( function() {
    3069                                 deferred.rejectWith( self, [ 'request failure' ] );
    3070                         });
    3071 
    3072                         this.request.done( function( response ) {
    3073                                 var location = self.request.getResponseHeader('Location'),
    3074                                         signature = self.signature,
    3075                                         index;
    3076 
    3077                                 // Check if the location response header differs from the current URL.
    3078                                 // If so, the request was redirected; try loading the requested page.
    3079                                 if ( location && location !== self.previewUrl() ) {
    3080                                         deferred.rejectWith( self, [ 'redirect', location ] );
    3081                                         return;
    3082                                 }
    3083 
    3084                                 // Check if the user is not logged in.
    3085                                 if ( '0' === response ) {
    3086                                         self.login( deferred );
    3087                                         return;
    3088                                 }
    3089 
    3090                                 // Check for cheaters.
    3091                                 if ( '-1' === response ) {
    3092                                         deferred.rejectWith( self, [ 'cheatin' ] );
    3093                                         return;
    3094                                 }
    3095 
    3096                                 // Check for a signature in the request.
    3097                                 index = response.lastIndexOf( signature );
    3098                                 if ( -1 === index || index < response.lastIndexOf('</html>') ) {
    3099                                         deferred.rejectWith( self, [ 'unsigned' ] );
    3100                                         return;
    3101                                 }
    3102 
    3103                                 // Strip the signature from the request.
    3104                                 response = response.slice( 0, index ) + response.slice( index + signature.length );
    3105 
    3106                                 // Create the iframe and inject the html content.
    3107                                 self.iframe = $( '<iframe />', { 'title': api.l10n.previewIframeTitle } ).appendTo( self.container );
    3108                                 self.iframe.attr( 'onmousewheel', '' ); // Workaround for Safari bug. See WP Trac #38149.
    3109 
    3110                                 // Bind load event after the iframe has been added to the page;
    3111                                 // otherwise it will fire when injected into the DOM.
    3112                                 self.iframe.one( 'load', function() {
    3113                                         loaded = true;
    3114 
    3115                                         if ( ready ) {
    3116                                                 deferred.resolveWith( self );
    3117                                         } else {
    3118                                                 setTimeout( function() {
    3119                                                         deferred.rejectWith( self, [ 'ready timeout' ] );
    3120                                                 }, self.sensitivity );
    3121                                         }
    3122                                 });
    3123 
    3124                                 self.targetWindow( self.iframe[0].contentWindow );
    3125 
    3126                                 self.targetWindow().document.open();
    3127                                 self.targetWindow().document.write( response );
    3128                                 self.targetWindow().document.close();
     3328                        previewFrame.iframe.one( 'load', function() {
     3329                                loaded = true;
     3330
     3331                                if ( ready ) {
     3332                                        deferred.resolveWith( previewFrame, [ readyData ] );
     3333                                } else {
     3334                                        setTimeout( function() {
     3335                                                deferred.rejectWith( previewFrame, [ 'ready timeout' ] );
     3336                                        }, previewFrame.sensitivity );
     3337                                }
    31293338                        });
    31303339                },
     
    31653374                destroy: function() {
    31663375                        api.Messenger.prototype.destroy.call( this );
    3167                         this.request.abort();
    3168 
    3169                         if ( this.iframe )
     3376
     3377                        if ( this.iframe ) {
    31703378                                this.iframe.remove();
    3171 
    3172                         delete this.request;
     3379                        }
     3380
    31733381                        delete this.iframe;
    31743382                        delete this.targetWindow;
     
    31773385
    31783386        (function(){
    3179                 var uuid = 0;
    3180                 /**
    3181                  * Create a universally unique identifier.
    3182                  *
    3183                  * @return {int}
     3387                var id = 0;
     3388                /**
     3389                 * Return an incremented ID for a preview messenger channel.
     3390                 *
     3391                 * This function is named "uuid" for historical reasons, but it is a
     3392                 * misnomer as it is not an actual UUID, and it is not universally unique.
     3393                 * This is not to be confused with `api.settings.changeset.uuid`.
     3394                 *
     3395                 * @return {string}
    31843396                 */
    31853397                api.PreviewFrame.uuid = function() {
    3186                         return 'preview-' + uuid++;
     3398                        return 'preview-' + String( id++ );
    31873399                };
    31883400        }());
     
    32103422         */
    32113423        api.Previewer = api.Messenger.extend({
    3212                 refreshBuffer: 250,
     3424                refreshBuffer: null, // Will get set to api.settings.timeouts.windowRefresh.
    32133425
    32143426                /**
     
    32183430                 * @param {string} params.form
    32193431                 * @param {string} params.previewUrl  The URL to preview.
    3220                  * @param {string} params.signature
    32213432                 * @param {object} options
    32223433                 */
    32233434                initialize: function( params, options ) {
    3224                         var self = this,
    3225                                 rscheme = /^https?/;
    3226 
    3227                         $.extend( this, options || {} );
    3228                         this.deferred = {
     3435                        var previewer = this,
     3436                                urlParser = document.createElement( 'a' );
     3437
     3438                        $.extend( previewer, options || {} );
     3439                        previewer.deferred = {
    32293440                                active: $.Deferred()
    32303441                        };
    32313442
    3232                         /*
    3233                          * Wrap this.refresh to prevent it from hammering the servers:
    3234                          *
    3235                          * If refresh is called once and no other refresh requests are
    3236                          * loading, trigger the request immediately.
    3237                          *
    3238                          * If refresh is called while another refresh request is loading,
    3239                          * debounce the refresh requests:
    3240                          * 1. Stop the loading request (as it is instantly outdated).
    3241                          * 2. Trigger the new request once refresh hasn't been called for
    3242                          *    self.refreshBuffer milliseconds.
    3243                          */
    3244                         this.refresh = (function( self ) {
    3245                                 var refresh  = self.refresh,
    3246                                         callback = function() {
    3247                                                 timeout = null;
    3248                                                 refresh.call( self );
    3249                                         },
    3250                                         timeout;
    3251 
    3252                                 return function() {
    3253                                         if ( typeof timeout !== 'number' ) {
    3254                                                 if ( self.loading ) {
    3255                                                         self.abort();
     3443                        // Debounce to prevent hammering server and then wait for any pending update requests.
     3444                        previewer.refresh = _.debounce(
     3445                                ( function( originalRefresh ) {
     3446                                        return function() {
     3447                                                var isProcessingComplete, refreshOnceProcessingComplete;
     3448                                                isProcessingComplete = function() {
     3449                                                        return 0 === api.state( 'processing' ).get();
     3450                                                };
     3451                                                if ( isProcessingComplete() ) {
     3452                                                        originalRefresh.call( previewer );
    32563453                                                } else {
    3257                                                         return callback();
     3454                                                        refreshOnceProcessingComplete = function() {
     3455                                                                if ( isProcessingComplete() ) {
     3456                                                                        originalRefresh.call( previewer );
     3457                                                                        api.state( 'processing' ).unbind( refreshOnceProcessingComplete );
     3458                                                                }
     3459                                                        };
     3460                                                        api.state( 'processing' ).bind( refreshOnceProcessingComplete );
    32583461                                                }
    3259                                         }
    3260 
    3261                                         clearTimeout( timeout );
    3262                                         timeout = setTimeout( callback, self.refreshBuffer );
    3263                                 };
    3264                         })( this );
    3265 
    3266                         this.container   = api.ensure( params.container );
    3267                         this.allowedUrls = params.allowedUrls;
    3268                         this.signature   = params.signature;
     3462                                        };
     3463                                }( previewer.refresh ) ),
     3464                                previewer.refreshBuffer
     3465                        );
     3466
     3467                        previewer.container   = api.ensure( params.container );
     3468                        previewer.allowedUrls = params.allowedUrls;
    32693469
    32703470                        params.url = window.location.href;
    32713471
    3272                         api.Messenger.prototype.initialize.call( this, params );
    3273 
    3274                         this.add( 'scheme', this.origin() ).link( this.origin ).setter( function( to ) {
    3275                                 var match = to.match( rscheme );
    3276                                 return match ? match[0] : '';
    3277                         });
     3472                        api.Messenger.prototype.initialize.call( previewer, params );
     3473
     3474                        urlParser.href = previewer.origin();
     3475                        previewer.add( 'scheme', urlParser.protocol.replace( /:$/, '' ) );
    32783476
    32793477                        // Limit the URL to internal, front-end links.
     
    32853483                        // ssl certs.
    32863484
    3287                         this.add( 'previewUrl', params.previewUrl ).setter( function( to ) {
    3288                                 var result, urlParser;
     3485                        previewer.add( 'previewUrl', params.previewUrl ).setter( function( to ) {
     3486                                var result, urlParser, newPreviewUrl, schemeMatchingPreviewUrl, queryParams;
    32893487                                urlParser = document.createElement( 'a' );
    32903488                                urlParser.href = to;
     
    32953493                                }
    32963494
     3495                                // Remove state query params.
     3496                                if ( urlParser.search.length > 1 ) {
     3497                                        queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
     3498                                        delete queryParams.customize_changeset_uuid;
     3499                                        delete queryParams.customize_theme;
     3500                                        delete queryParams.customize_messenger_channel;
     3501                                        if ( _.isEmpty( queryParams ) ) {
     3502                                                urlParser.search = '';
     3503                                        } else {
     3504                                                urlParser.search = $.param( queryParams );
     3505                                        }
     3506                                }
     3507
     3508                                newPreviewUrl = urlParser.href;
     3509                                urlParser.protocol = previewer.scheme.get() + ':';
     3510                                schemeMatchingPreviewUrl = urlParser.href;
     3511
    32973512                                // Attempt to match the URL to the control frame's scheme
    32983513                                // and check if it's allowed. If not, try the original URL.
    3299                                 $.each([ to.replace( rscheme, self.scheme() ), to ], function( i, url ) {
    3300                                         $.each( self.allowedUrls, function( i, allowed ) {
     3514                                $.each( [ schemeMatchingPreviewUrl, newPreviewUrl ], function( i, url ) {
     3515                                        $.each( previewer.allowedUrls, function( i, allowed ) {
    33013516                                                var path;
    33023517
     
    33093524                                                }
    33103525                                        });
    3311                                         if ( result )
     3526                                        if ( result ) {
    33123527                                                return false;
     3528                                        }
    33133529                                });
    33143530
     
    33173533                        });
    33183534
     3535                        previewer.bind( 'ready', previewer.ready );
     3536
     3537                        // Start listening for keep-alive messages when iframe first loads.
     3538                        previewer.deferred.active.done( _.bind( previewer.keepPreviewAlive, previewer ) );
     3539
     3540                        previewer.bind( 'synced', function() {
     3541                                previewer.send( 'active' );
     3542                        } );
     3543
    33193544                        // Refresh the preview when the URL is changed (but not yet).
    3320                         this.previewUrl.bind( this.refresh );
    3321 
    3322                         this.scroll = 0;
    3323                         this.bind( 'scroll', function( distance ) {
    3324                                 this.scroll = distance;
    3325                         });
    3326 
    3327                         // Update the URL when the iframe sends a URL message.
    3328                         this.bind( 'url', this.previewUrl );
     3545                        previewer.previewUrl.bind( previewer.refresh );
     3546
     3547                        previewer.scroll = 0;
     3548                        previewer.bind( 'scroll', function( distance ) {
     3549                                previewer.scroll = distance;
     3550                        });
     3551
     3552                        // Update the URL when the iframe sends a URL message, resetting scroll position. If URL is unchanged, then refresh.
     3553                        previewer.bind( 'url', function( url ) {
     3554                                var onUrlChange, urlChanged = false;
     3555                                previewer.scroll = 0;
     3556                                onUrlChange = function() {
     3557                                        urlChanged = true;
     3558                                };
     3559                                previewer.previewUrl.bind( onUrlChange );
     3560                                previewer.previewUrl.set( url );
     3561                                previewer.previewUrl.unbind( onUrlChange );
     3562                                if ( ! urlChanged ) {
     3563                                        previewer.refresh();
     3564                                }
     3565                        } );
    33293566
    33303567                        // Update the document title when the preview changes.
    3331                         this.bind( 'documentTitle', function ( title ) {
     3568                        previewer.bind( 'documentTitle', function ( title ) {
    33323569                                api.setDocumentTitle( title );
    33333570                        } );
     3571                },
     3572
     3573                /**
     3574                 * Handle the preview receiving the ready message.
     3575                 *
     3576                 * @since 4.7.0
     3577                 *
     3578                 * @param {object} data - Data from preview.
     3579                 * @param {string} data.currentUrl - Current URL.
     3580                 * @param {object} data.activePanels - Active panels.
     3581                 * @param {object} data.activeSections Active sections.
     3582                 * @param {object} data.activeControls Active controls.
     3583                 * @returns {void}
     3584                 */
     3585                ready: function( data ) {
     3586                        var previewer = this, synced = {}, constructs;
     3587
     3588                        synced.settings = api.get();
     3589                        if ( 'resolved' !== previewer.deferred.active.state() || previewer.loading ) {
     3590                                synced.scroll = previewer.scroll;
     3591                        }
     3592                        previewer.send( 'sync', synced );
     3593
     3594                        // Set the previewUrl without causing the url to set the iframe.
     3595                        if ( data.currentUrl ) {
     3596                                previewer.previewUrl.unbind( previewer.refresh );
     3597                                previewer.previewUrl.set( data.currentUrl );
     3598                                previewer.previewUrl.bind( previewer.refresh );
     3599                        }
     3600
     3601                        /*
     3602                         * Walk over all panels, sections, and controls and set their
     3603                         * respective active states to true if the preview explicitly
     3604                         * indicates as such.
     3605                         */
     3606                        constructs = {
     3607                                panel: data.activePanels,
     3608                                section: data.activeSections,
     3609                                control: data.activeControls
     3610                        };
     3611                        _( constructs ).each( function ( activeConstructs, type ) {
     3612                                api[ type ].each( function ( construct, id ) {
     3613                                        var isDynamicallyCreated = _.isUndefined( api.settings[ type + 's' ][ id ] );
     3614
     3615                                        /*
     3616                                         * If the construct was created statically in PHP (not dynamically in JS)
     3617                                         * then consider a missing (undefined) value in the activeConstructs to
     3618                                         * mean it should be deactivated (since it is gone). But if it is
     3619                                         * dynamically created then only toggle activation if the value is defined,
     3620                                         * as this means that the construct was also then correspondingly
     3621                                         * created statically in PHP and the active callback is available.
     3622                                         * Otherwise, dynamically-created constructs should normally have
     3623                                         * their active states toggled in JS rather than from PHP.
     3624                                         */
     3625                                        if ( ! isDynamicallyCreated || ! _.isUndefined( activeConstructs[ id ] ) ) {
     3626                                                if ( activeConstructs[ id ] ) {
     3627                                                        construct.activate();
     3628                                                } else {
     3629                                                        construct.deactivate();
     3630                                                }
     3631                                        }
     3632                                } );
     3633                        } );
     3634
     3635                        if ( data.settingValidities ) {
     3636                                api._handleSettingValidities( {
     3637                                        settingValidities: data.settingValidities,
     3638                                        focusInvalidControl: false
     3639                                } );
     3640                        }
     3641                },
     3642
     3643                /**
     3644                 * Keep the preview alive by listening for ready and keep-alive messages.
     3645                 *
     3646                 * If a message is not received in the allotted time then the iframe will be set back to the last known valid URL.
     3647                 *
     3648                 * @since 4.7.0
     3649                 *
     3650                 * @returns {void}
     3651                 */
     3652                keepPreviewAlive: function keepPreviewAlive() {
     3653                        var previewer = this, keepAliveTick, timeoutId, handleMissingKeepAlive, scheduleKeepAliveCheck;
     3654
     3655                        /**
     3656                         * Schedule a preview keep-alive check.
     3657                         *
     3658                         * Note that if a page load takes longer than keepAliveCheck milliseconds,
     3659                         * the keep-alive messages will still be getting sent from the previous
     3660                         * URL.
     3661                         */
     3662                        scheduleKeepAliveCheck = function() {
     3663                                timeoutId = setTimeout( handleMissingKeepAlive, api.settings.timeouts.keepAliveCheck );
     3664                        };
     3665
     3666                        /**
     3667                         * Set the previewerAlive state to true when receiving a message from the preview.
     3668                         */
     3669                        keepAliveTick = function() {
     3670                                api.state( 'previewerAlive' ).set( true );
     3671                                clearTimeout( timeoutId );
     3672                                scheduleKeepAliveCheck();
     3673                        };
     3674
     3675                        /**
     3676                         * Set the previewerAlive state to false if keepAliveCheck milliseconds have transpired without a message.
     3677                         *
     3678                         * This is most likely to happen in the case of a connectivity error, or if the theme causes the browser
     3679                         * to navigate to a non-allowed URL. Setting this state to false will force settings with a postMessage
     3680                         * transport to use refresh instead, causing the preview frame also to be replaced with the current
     3681                         * allowed preview URL.
     3682                         */
     3683                        handleMissingKeepAlive = function() {
     3684                                api.state( 'previewerAlive' ).set( false );
     3685                        };
     3686                        scheduleKeepAliveCheck();
     3687
     3688                        previewer.bind( 'ready', keepAliveTick );
     3689                        previewer.bind( 'keep-alive', keepAliveTick );
    33343690                },
    33353691
     
    33493705
    33503706                /**
    3351                  * Refresh the preview.
     3707                 * Refresh the preview seamlessly.
    33523708                 */
    33533709                refresh: function() {
    3354                         var self = this;
     3710                        var previewer = this;
    33553711
    33563712                        // Display loading indicator
    3357                         this.send( 'loading-initiated' );
    3358 
    3359                         this.abort();
    3360 
    3361                         this.loading = new api.PreviewFrame({
    3362                                 url:        this.url(),
    3363                                 previewUrl: this.previewUrl(),
    3364                                 query:      this.query() || {},
    3365                                 container:  this.container,
    3366                                 signature:  this.signature
    3367                         });
    3368 
    3369                         this.loading.done( function() {
    3370                                 // 'this' is the loading frame
    3371                                 this.bind( 'synced', function() {
    3372                                         if ( self.preview )
    3373                                                 self.preview.destroy();
    3374                                         self.preview = this;
    3375                                         delete self.loading;
    3376 
    3377                                         self.targetWindow( this.targetWindow() );
    3378                                         self.channel( this.channel() );
    3379 
    3380                                         self.deferred.active.resolve();
    3381                                         self.send( 'active' );
    3382                                 });
    3383 
    3384                                 this.send( 'sync', {
    3385                                         scroll:   self.scroll,
    3386                                         settings: api.get()
    3387                                 });
    3388                         });
    3389 
    3390                         this.loading.fail( function( reason, location ) {
    3391                                 self.send( 'loading-failed' );
    3392                                 if ( 'redirect' === reason && location ) {
    3393                                         self.previewUrl( location );
    3394                                 }
     3713                        previewer.send( 'loading-initiated' );
     3714
     3715                        previewer.abort();
     3716
     3717                        previewer.loading = new api.PreviewFrame({
     3718                                url:        previewer.url(),
     3719                                previewUrl: previewer.previewUrl(),
     3720                                query:      previewer.query( { excludeCustomizedSaved: true } ) || {},
     3721                                container:  previewer.container
     3722                        });
     3723
     3724                        previewer.loading.done( function( readyData ) {
     3725                                var loadingFrame = this, previousPreview, onceSynced;
     3726
     3727                                previousPreview = previewer.preview;
     3728                                previewer.preview = loadingFrame;
     3729                                previewer.targetWindow( loadingFrame.targetWindow() );
     3730                                previewer.channel( loadingFrame.channel() );
     3731
     3732                                onceSynced = function() {
     3733                                        loadingFrame.unbind( 'synced', onceSynced );
     3734                                        if ( previousPreview ) {
     3735                                                previousPreview.destroy();
     3736                                        }
     3737                                        previewer.deferred.active.resolve();
     3738                                        delete previewer.loading;
     3739                                };
     3740                                loadingFrame.bind( 'synced', onceSynced );
     3741
     3742                                // This event will be received directly by the previewer in normal navigation; this is only needed for seamless refresh.
     3743                                previewer.trigger( 'ready', readyData );
     3744                        });
     3745
     3746                        previewer.loading.fail( function( reason ) {
     3747                                previewer.send( 'loading-failed' );
    33953748
    33963749                                if ( 'logged out' === reason ) {
    3397                                         if ( self.preview ) {
    3398                                                 self.preview.destroy();
    3399                                                 delete self.preview;
     3750                                        if ( previewer.preview ) {
     3751                                                previewer.preview.destroy();
     3752                                                delete previewer.preview;
    34003753                                        }
    34013754
    3402                                         self.login().done( self.refresh );
     3755                                        previewer.login().done( previewer.refresh );
    34033756                                }
    34043757
    34053758                                if ( 'cheatin' === reason ) {
    3406                                         self.cheatin();
     3759                                        previewer.cheatin();
    34073760                                }
    34083761                        });
     
    34643817                        request = wp.ajax.post( 'customize_refresh_nonces', {
    34653818                                wp_customize: 'on',
    3466                                 theme: api.settings.theme.stylesheet
     3819                                customize_theme: api.settings.theme.stylesheet
    34673820                        });
    34683821
     
    36804033                }
    36814034
     4035                if ( null === api.PreviewFrame.prototype.sensitivity ) {
     4036                        api.PreviewFrame.prototype.sensitivity = api.settings.timeouts.previewFrameSensitivity;
     4037                }
     4038                if ( null === api.Previewer.prototype.refreshBuffer ) {
     4039                        api.Previewer.prototype.refreshBuffer = api.settings.timeouts.windowRefresh;
     4040                }
     4041
    36824042                var parent,
    36834043                        body = $( document.body ),
     
    37234083                        form:        '#customize-controls',
    37244084                        previewUrl:  api.settings.url.preview,
    3725                         allowedUrls: api.settings.url.allowed,
    3726                         signature:   'WP_CUSTOMIZER_SIGNATURE'
     4085                        allowedUrls: api.settings.url.allowed
    37274086                }, {
    37284087
     
    37324091                         * Build the query to send along with the Preview request.
    37334092                         *
    3734                          * @return {object}
     4093                         * @since 4.7.0 Added options param.
     4094                         *
     4095                         * @param {object}  [options] Options.
     4096                         * @param {boolean} [options.excludeCustomizedSaved=false] Exclude saved settings in customized response (values pending writing to changeset).
     4097                         * @return {object} Query vars.
    37354098                         */
    3736                         query: function() {
    3737                                 var dirtyCustomized = {};
    3738                                 api.each( function ( value, key ) {
    3739                                         if ( value._dirty ) {
    3740                                                 dirtyCustomized[ key ] = value();
    3741                                         }
    3742                                 } );
    3743 
    3744                                 return {
     4099                        query: function( options ) {
     4100                                var queryVars = {
    37454101                                        wp_customize: 'on',
    3746                                         theme:      api.settings.theme.stylesheet,
    3747                                         customized: JSON.stringify( dirtyCustomized ),
    3748                                         nonce:      this.nonce.preview
     4102                                        customize_theme: api.settings.theme.stylesheet,
     4103                                        nonce: this.nonce.preview,
     4104                                        customize_changeset_uuid: api.settings.changeset.uuid
    37494105                                };
     4106
     4107                                /*
     4108                                 * Exclude customized data if requested especially for calls to requestChangesetUpdate.
     4109                                 * Changeset updates are differential and so it is a performance waste to send all of
     4110                                 * the dirty settings with each update.
     4111                                 */
     4112                                queryVars.customized = JSON.stringify( api.dirtyValues( {
     4113                                        unsaved: options && options.excludeCustomizedSaved
     4114                                } ) );
     4115
     4116                                return queryVars;
    37504117                        },
    37514118
    3752                         save: function() {
    3753                                 var self = this,
     4119                        /**
     4120                         * Save (and publish) the customizer changeset.
     4121                         *
     4122                         * Updates to the changeset are transactional. If any of the settings
     4123                         * are invalid then none of them will be written into the changeset.
     4124                         * A revision will be made for the changeset post if revisions support
     4125                         * has been added to the post type.
     4126                         *
     4127                         * @param {object} [args] Args.
     4128                         * @param {string} [args.status=publish] Status.
     4129                         * @param {string} [args.date] Date, in local time in MySQL format.
     4130                         * @param {string} [args.title] Title
     4131                         *
     4132                         * @returns {jQuery.promise}
     4133                         */
     4134                        save: function( args ) {
     4135                                var previewer = this,
     4136                                        deferred = $.Deferred(),
     4137                                        changesetStatus = 'publish',
    37544138                                        processing = api.state( 'processing' ),
    37554139                                        submitWhenDoneProcessing,
     
    37594143                                        invalidControls;
    37604144
    3761                                 body.addClass( 'saving' );
     4145                                if ( args && args.status ) {
     4146                                        changesetStatus = args.status;
     4147                                }
     4148
     4149                                if ( api.state( 'saving' ).get() ) {
     4150                                        deferred.reject( 'already_saving' );
     4151                                        deferred.promise();
     4152                                }
     4153
     4154                                api.state( 'saving' ).set( true );
    37624155
    37634156                                function captureSettingModifiedDuringSave( setting ) {
     
    37674160
    37684161                                submit = function () {
    3769                                         var request, query;
     4162                                        var request, query, settingInvalidities = {};
    37704163
    37714164                                        /*
     
    37784171                                                        if ( 'error' === notification.type && ! notification.fromServer ) {
    37794172                                                                invalidSettings.push( setting.id );
     4173                                                                if ( ! settingInvalidities[ setting.id ] ) {
     4174                                                                        settingInvalidities[ setting.id ] = {};
     4175                                                                }
     4176                                                                settingInvalidities[ setting.id ][ notification.code ] = notification;
    37804177                                                        }
    37814178                                                } );
     
    37844181                                        if ( ! _.isEmpty( invalidControls ) ) {
    37854182                                                _.values( invalidControls )[0][0].focus();
    3786                                                 body.removeClass( 'saving' );
    37874183                                                api.unbind( 'change', captureSettingModifiedDuringSave );
    3788                                                 return;
     4184                                                deferred.rejectWith( previewer, [
     4185                                                        { setting_invalidities: settingInvalidities }
     4186                                                ] );
     4187                                                api.state( 'saving' ).set( false );
     4188                                                return deferred.promise();
    37894189                                        }
    37904190
    3791                                         query = $.extend( self.query(), {
    3792                                                 nonce:  self.nonce.save
     4191                                        /*
     4192                                         * Note that excludeCustomizedSaved is intentionally false so that the entire
     4193                                         * set of customized data will be included if bypassed changeset update.
     4194                                         */
     4195                                        query = $.extend( previewer.query( { excludeCustomizedSaved: false } ), {
     4196                                                nonce: previewer.nonce.save,
     4197                                                customize_changeset_status: changesetStatus
    37934198                                        } );
     4199                                        if ( args && args.date ) {
     4200                                                query.customize_changeset_date = args.date;
     4201                                        }
     4202                                        if ( args && args.title ) {
     4203                                                query.customize_changeset_title = args.title;
     4204                                        }
     4205
     4206                                        /*
     4207                                         * Note that the dirty customized values will have already been set in the
     4208                                         * changeset and so technically query.customized could be deleted. However,
     4209                                         * it is remaining here to make sure that any settings that got updated
     4210                                         * quietly which may have not triggered an update request will also get
     4211                                         * included in the values that get saved to the changeset. This will ensure
     4212                                         * that values that get injected via the saved event will be included in
     4213                                         * the changeset. This also ensures that setting values that were invalid
     4214                                         * will get re-validated, perhaps in the case of settings that are invalid
     4215                                         * due to dependencies on other settings.
     4216                                         */
    37944217                                        request = wp.ajax.post( 'customize_save', query );
    37954218
     
    38004223
    38014224                                        request.always( function () {
    3802                                                 body.removeClass( 'saving' );
     4225                                                api.state( 'saving' ).set( false );
    38034226                                                saveBtn.prop( 'disabled', false );
    38044227                                                api.unbind( 'change', captureSettingModifiedDuringSave );
     
    38064229
    38074230                                        request.fail( function ( response ) {
     4231
    38084232                                                if ( '0' === response ) {
    38094233                                                        response = 'not_logged_in';
     
    38144238
    38154239                                                if ( 'invalid_nonce' === response ) {
    3816                                                         self.cheatin();
     4240                                                        previewer.cheatin();
    38174241                                                } else if ( 'not_logged_in' === response ) {
    3818                                                         self.preview.iframe.hide();
    3819                                                         self.login().done( function() {
    3820                                                                 self.save();
    3821                                                                 self.preview.iframe.show();
     4242                                                        previewer.preview.iframe.hide();
     4243                                                        previewer.login().done( function() {
     4244                                                                previewer.save();
     4245                                                                previewer.preview.iframe.show();
    38224246                                                        } );
    38234247                                                }
     
    38304254                                                }
    38314255
     4256                                                deferred.rejectWith( previewer, [ response ] );
    38324257                                                api.trigger( 'error', response );
    38334258                                        } );
     
    38354260                                        request.done( function( response ) {
    38364261
    3837                                                 // Clear setting dirty states, if setting wasn't modified while saving.
    3838                                                 api.each( function( setting ) {
    3839                                                         if ( ! modifiedWhileSaving[ setting.id ] ) {
    3840                                                                 setting._dirty = false;
    3841                                                         }
    3842                                                 } );
    3843 
    3844                                                 api.previewer.send( 'saved', response );
     4262                                                previewer.send( 'saved', response );
     4263
     4264                                                api.state( 'changesetStatus' ).set( response.changeset_status );
     4265                                                if ( 'publish' === response.changeset_status ) {
     4266                                                        api.state( 'changesetStatus' ).set( '' );
     4267                                                        api.settings.changeset.uuid = response.next_changeset_uuid;
     4268                                                        parent.send( 'changeset-uuid', api.settings.changeset.uuid );
     4269                                                }
    38454270
    38464271                                                if ( response.setting_validities ) {
     
    38514276                                                }
    38524277
     4278                                                deferred.resolveWith( previewer, [ response ] );
    38534279                                                api.trigger( 'saved', response );
    38544280
     
    38724298                                }
    38734299
     4300                                return deferred.promise();
    38744301                        }
    38754302                });
     
    39584385                api.bind( 'ready', api.reflowPaneContents );
    39594386                $( [ api.panel, api.section, api.control ] ).each( function ( i, values ) {
    3960                         var debouncedReflowPaneContents = _.debounce( api.reflowPaneContents, 100 );
     4387                        var debouncedReflowPaneContents = _.debounce( api.reflowPaneContents, api.settings.timeouts.reflowPaneContents );
    39614388                        values.bind( 'add', debouncedReflowPaneContents );
    39624389                        values.bind( 'change', debouncedReflowPaneContents );
    39634390                        values.bind( 'remove', debouncedReflowPaneContents );
    39644391                } );
     4392
     4393                // Save and activated states
     4394                (function() {
     4395                        var state = new api.Values(),
     4396                                saved = state.create( 'saved' ),
     4397                                saving = state.create( 'saving' ),
     4398                                activated = state.create( 'activated' ),
     4399                                processing = state.create( 'processing' ),
     4400                                paneVisible = state.create( 'paneVisible' ),
     4401                                changesetStatus = state.create( 'changesetStatus' ),
     4402                                previewerAlive = state.create( 'previewerAlive' ),
     4403                                populateChangesetUuidParam;
     4404
     4405                        state.bind( 'change', function() {
     4406                                var canSave;
     4407
     4408                                if ( ! activated() ) {
     4409                                        saveBtn.val( api.l10n.activate );
     4410                                        closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel );
     4411
     4412                                } else if ( '' === changesetStatus.get() && saved() ) {
     4413                                        saveBtn.val( api.l10n.saved );
     4414                                        closeBtn.find( '.screen-reader-text' ).text( api.l10n.close );
     4415
     4416                                } else {
     4417                                        saveBtn.val( api.l10n.save );
     4418                                        closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel );
     4419                                }
     4420
     4421                                /*
     4422                                 * Save (publish) button should be enabled if saving is not currently happening,
     4423                                 * and if the theme is not active or the changeset exists but is not published.
     4424                                 */
     4425                                canSave = ! saving() && ( ! activated() || ! saved() || ( '' !== changesetStatus() && 'publish' !== changesetStatus() ) );
     4426
     4427                                saveBtn.prop( 'disabled', ! canSave );
     4428                        });
     4429
     4430                        // Set default states.
     4431                        saved( true );
     4432                        saving( false );
     4433                        activated( api.settings.theme.active );
     4434                        processing( 0 );
     4435                        paneVisible( true );
     4436                        previewerAlive( true );
     4437                        changesetStatus( api.settings.changeset.status );
     4438
     4439                        api.bind( 'change', function() {
     4440                                state('saved').set( false );
     4441                        });
     4442
     4443                        saving.bind( function( isSaving ) {
     4444                                body.toggleClass( 'saving', isSaving );
     4445                        } );
     4446
     4447                        api.bind( 'saved', function( response ) {
     4448                                state('saved').set( true );
     4449                                if ( 'publish' === response.changeset_status ) {
     4450                                        state( 'activated' ).set( true );
     4451                                }
     4452                        });
     4453
     4454                        activated.bind( function( to ) {
     4455                                if ( to ) {
     4456                                        api.trigger( 'activated' );
     4457                                }
     4458                        });
     4459
     4460                        populateChangesetUuidParam = function( isIncluded ) {
     4461                                var urlParser, queryParams;
     4462                                urlParser = document.createElement( 'a' );
     4463                                urlParser.href = location.href;
     4464                                queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
     4465                                if ( isIncluded ) {
     4466                                        if ( queryParams.changeset_uuid === api.settings.changeset.uuid ) {
     4467                                                return;
     4468                                        }
     4469                                        queryParams.changeset_uuid = api.settings.changeset.uuid;
     4470                                } else {
     4471                                        if ( ! queryParams.changeset_uuid ) {
     4472                                                return;
     4473                                        }
     4474                                        delete queryParams.changeset_uuid;
     4475                                }
     4476                                urlParser.search = $.param( queryParams );
     4477                                history.replaceState( {}, document.title, urlParser.href );
     4478                        };
     4479
     4480                        if ( history.replaceState ) {
     4481                                saved.bind( function( isSaved ) {
     4482                                        if ( ! isSaved ) {
     4483                                                populateChangesetUuidParam( true );
     4484                                        }
     4485                                } );
     4486                                changesetStatus.bind( function( newStatus ) {
     4487                                        populateChangesetUuidParam( '' !== newStatus );
     4488                                } );
     4489                        }
     4490
     4491                        // Expose states to the API.
     4492                        api.state = state;
     4493                }());
    39654494
    39664495                // Check if preview url is valid and load the preview frame.
     
    39704499                        api.previewer.previewUrl( api.settings.url.home );
    39714500                }
    3972 
    3973                 // Save and activated states
    3974                 (function() {
    3975                         var state = new api.Values(),
    3976                                 saved = state.create( 'saved' ),
    3977                                 activated = state.create( 'activated' ),
    3978                                 processing = state.create( 'processing' ),
    3979                                 paneVisible = state.create( 'paneVisible' );
    3980 
    3981                         state.bind( 'change', function() {
    3982                                 if ( ! activated() ) {
    3983                                         saveBtn.val( api.l10n.activate ).prop( 'disabled', false );
    3984                                         closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel );
    3985 
    3986                                 } else if ( saved() ) {
    3987                                         saveBtn.val( api.l10n.saved ).prop( 'disabled', true );
    3988                                         closeBtn.find( '.screen-reader-text' ).text( api.l10n.close );
    3989 
    3990                                 } else {
    3991                                         saveBtn.val( api.l10n.save ).prop( 'disabled', false );
    3992                                         closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel );
    3993                                 }
    3994                         });
    3995 
    3996                         // Set default states.
    3997                         saved( true );
    3998                         activated( api.settings.theme.active );
    3999                         processing( 0 );
    4000                         paneVisible( true );
    4001 
    4002                         api.bind( 'change', function() {
    4003                                 state('saved').set( false );
    4004                         });
    4005 
    4006                         api.bind( 'saved', function() {
    4007                                 state('saved').set( true );
    4008                                 state('activated').set( true );
    4009                         });
    4010 
    4011                         activated.bind( function( to ) {
    4012                                 if ( to ) {
    4013                                         api.trigger( 'activated' );
    4014                                 }
    4015                         });
    4016 
    4017                         // Expose states to the API.
    4018                         api.state = state;
    4019                 }());
    40204501
    40214502                // Button bindings.
     
    41704651
    41714652                // Prompt user with AYS dialog if leaving the Customizer with unsaved changes
    4172                 $( window ).on( 'beforeunload', function () {
     4653                $( window ).on( 'beforeunload.customize-confirm', function () {
    41734654                        if ( ! api.state( 'saved' )() ) {
    41744655                                setTimeout( function() {
     
    41904671                        parent.send( 'title', newTitle );
    41914672                });
     4673
     4674                parent.send( 'changeset-uuid', api.settings.changeset.uuid );
    41924675
    41934676                // Initialize the connection with the parent frame.
     
    42934776                });
    42944777
     4778                // Autosave changeset.
     4779                ( function() {
     4780                        var timeoutId, updateChangesetWithReschedule, scheduleChangesetUpdate, updatePending = false;
     4781
     4782                        /**
     4783                         * Request changeset update and then re-schedule the next changeset update time.
     4784                         *
     4785                         * @private
     4786                         */
     4787                        updateChangesetWithReschedule = function() {
     4788                                if ( ! updatePending ) {
     4789                                        updatePending = true;
     4790                                        api.requestChangesetUpdate().always( function() {
     4791                                                updatePending = false;
     4792                                        } );
     4793                                }
     4794                                scheduleChangesetUpdate();
     4795                        };
     4796
     4797                        /**
     4798                         * Schedule changeset update.
     4799                         *
     4800                         * @private
     4801                         */
     4802                        scheduleChangesetUpdate = function() {
     4803                                clearTimeout( timeoutId );
     4804                                timeoutId = setTimeout( function() {
     4805                                        updateChangesetWithReschedule();
     4806                                }, api.settings.timeouts.changesetAutoSave );
     4807                        };
     4808
     4809                        // Start auto-save interval for updating changeset.
     4810                        scheduleChangesetUpdate();
     4811
     4812                        // Save changeset when focus removed from window.
     4813                        $( window ).on( 'blur.wp-customize-changeset-update', function() {
     4814                                updateChangesetWithReschedule();
     4815                        } );
     4816
     4817                        // Save changeset before unloading window.
     4818                        $( window ).on( 'beforeunload.wp-customize-changeset-update', function() {
     4819                                updateChangesetWithReschedule();
     4820                        } );
     4821                } ());
     4822
    42954823                api.trigger( 'ready' );
    42964824        });
  • trunk/src/wp-admin/js/customize-widgets.js

    r38709 r38810  
    11551155                        params.wp_customize = 'on';
    11561156                        params.nonce = api.settings.nonce['update-widget'];
    1157                         params.theme = api.settings.theme.stylesheet;
     1157                        params.customize_theme = api.settings.theme.stylesheet;
    11581158                        params.customized = wp.customize.previewer.query().customized;
    11591159
  • trunk/src/wp-includes/admin-bar.php

    r38708 r38810  
    367367 *
    368368 * @param WP_Admin_Bar $wp_admin_bar WP_Admin_Bar instance.
     369 * @global WP_Customize_Manager $wp_customize
    369370 */
    370371function wp_admin_bar_customize_menu( $wp_admin_bar ) {
     372        global $wp_customize;
     373
    371374        // Don't show for users who can't access the customizer or when in the admin.
    372375        if ( ! current_user_can( 'customize' ) || is_admin() ) {
     
    374377        }
    375378
     379        // Don't show if the user cannot edit a given customize_changeset post currently being previewed.
     380        if ( is_customize_preview() && $wp_customize->changeset_post_id() && ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $wp_customize->changeset_post_id() ) ) {
     381                return;
     382        }
     383
    376384        $current_url = ( is_ssl() ? 'https://' : 'http://' ) . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
     385        if ( is_customize_preview() && $wp_customize->changeset_uuid() ) {
     386                $current_url = remove_query_arg( 'customize_changeset_uuid', $current_url );
     387        }
     388
    377389        $customize_url = add_query_arg( 'url', urlencode( $current_url ), wp_customize_url() );
     390        if ( is_customize_preview() ) {
     391                $customize_url = add_query_arg( array( 'changeset_uuid' => $wp_customize->changeset_uuid() ), $customize_url );
     392        }
    378393
    379394        $wp_admin_bar->add_menu( array(
  • trunk/src/wp-includes/class-wp-customize-manager.php

    r38765 r38810  
    131131
    132132        /**
    133          * Return value of check_ajax_referer() in customize_preview_init() method.
    134          *
    135          * @since 3.5.0
    136          * @access protected
    137          * @var false|int
    138          */
    139         protected $nonce_tick;
    140 
    141         /**
    142133         * Panel types that may be rendered from JS templates.
    143134         *
     
    194185
    195186        /**
     187         * Messenger channel.
     188         *
     189         * @since 4.7.0
     190         * @access protected
     191         * @var string
     192         */
     193        protected $messenger_channel;
     194
     195        /**
    196196         * Unsanitized values for Customize Settings parsed from $_POST['customized'].
    197197         *
     
    201201
    202202        /**
     203         * Changeset UUID.
     204         *
     205         * @since 4.7.0
     206         * @access private
     207         * @var string
     208         */
     209        private $_changeset_uuid;
     210
     211        /**
     212         * Changeset post ID.
     213         *
     214         * @since 4.7.0
     215         * @access private
     216         * @var int|false
     217         */
     218        private $_changeset_post_id;
     219
     220        /**
     221         * Changeset data loaded from a customize_changeset post.
     222         *
     223         * @since 4.7.0
     224         * @access private
     225         * @var array
     226         */
     227        private $_changeset_data;
     228
     229        /**
    203230         * Constructor.
    204231         *
    205232         * @since 3.4.0
    206          */
    207         public function __construct() {
     233         * @since 4.7.0 Added $args param.
     234         *
     235         * @param array $args {
     236         *     Args.
     237         *
     238         *     @type string $changeset_uuid    Changeset UUID, the post_name for the customize_changeset post containing the customized state. Defaults to new UUID.
     239         *     @type string $theme             Theme to be previewed (for theme switch). Defaults to customize_theme or theme query params.
     240         *     @type string $messenger_channel Messenger channel. Defaults to customize_messenger_channel query param.
     241         * }
     242         */
     243        public function __construct( $args = array() ) {
     244
     245                $args = array_merge(
     246                        array_fill_keys( array( 'changeset_uuid', 'theme', 'messenger_channel' ), null ),
     247                        $args
     248                );
     249
     250                // Note that the UUID format will be validated in the setup_theme() method.
     251                if ( ! isset( $args['changeset_uuid'] ) ) {
     252                        $args['changeset_uuid'] = wp_generate_uuid4();
     253                }
     254
     255                // The theme and messenger_channel should be supplied via $args, but they are also looked at in the $_REQUEST global here for back-compat.
     256                if ( ! isset( $args['theme'] ) ) {
     257                        if ( isset( $_REQUEST['customize_theme'] ) ) {
     258                                $args['theme'] = wp_unslash( $_REQUEST['customize_theme'] );
     259                        } elseif ( isset( $_REQUEST['theme'] ) ) { // Deprecated.
     260                                $args['theme'] = wp_unslash( $_REQUEST['theme'] );
     261                        }
     262                }
     263                if ( ! isset( $args['messenger_channel'] ) && isset( $_REQUEST['customize_messenger_channel'] ) ) {
     264                        $args['messenger_channel'] = sanitize_key( wp_unslash( $_REQUEST['customize_messenger_channel'] ) );
     265                }
     266
     267                $this->original_stylesheet = get_stylesheet();
     268                $this->theme = wp_get_theme( $args['theme'] );
     269                $this->messenger_channel = $args['messenger_channel'];
     270                $this->_changeset_uuid = $args['changeset_uuid'];
     271
    208272                require_once( ABSPATH . WPINC . '/class-wp-customize-setting.php' );
    209273                require_once( ABSPATH . WPINC . '/class-wp-customize-panel.php' );
     
    272336                }
    273337
    274                 add_filter( 'wp_die_handler', array( $this, 'wp_die_handler' ) );
    275 
    276338                add_action( 'setup_theme', array( $this, 'setup_theme' ) );
    277339                add_action( 'wp_loaded',   array( $this, 'wp_loaded' ) );
    278 
    279                 // Run wp_redirect_status late to make sure we override the status last.
    280                 add_action( 'wp_redirect_status', array( $this, 'wp_redirect_status' ), 1000 );
    281340
    282341                // Do not spawn cron (especially the alternate cron) while running the Customizer.
     
    341400         */
    342401        protected function wp_die( $ajax_message, $message = null ) {
    343                 if ( $this->doing_ajax() || isset( $_POST['customized'] ) ) {
     402                if ( $this->doing_ajax() ) {
    344403                        wp_die( $ajax_message );
    345404                }
     
    349408                }
    350409
     410                if ( $this->messenger_channel ) {
     411                        ob_start();
     412                        wp_enqueue_scripts();
     413                        wp_print_scripts( array( 'customize-base' ) );
     414
     415                        $settings = array(
     416                                'messengerArgs' => array(
     417                                        'channel' => $this->messenger_channel,
     418                                        'url' => wp_customize_url(),
     419                                ),
     420                                'error' => $ajax_message,
     421                        );
     422                        ?>
     423                        <script>
     424                        ( function( api, settings ) {
     425                                var preview = new api.Messenger( settings.messengerArgs );
     426                                preview.send( 'iframe-loading-error', settings.error );
     427                        } )( wp.customize, <?php echo wp_json_encode( $settings ) ?> );
     428                        </script>
     429                        <?php
     430                        $message .= ob_get_clean();
     431                }
     432
    351433                wp_die( $message );
    352434        }
     
    356438         *
    357439         * @since 3.4.0
    358          *
    359          * @return string
     440         * @deprecated 4.7.0
     441         *
     442         * @return callable Die handler.
    360443         */
    361444        public function wp_die_handler() {
     445                _deprecated_function( __METHOD__, '4.7.0' );
     446
    362447                if ( $this->doing_ajax() || isset( $_POST['customized'] ) ) {
    363448                        return '_ajax_wp_die_handler';
     
    375460         */
    376461        public function setup_theme() {
    377                 send_origin_headers();
    378 
    379                 $doing_ajax_or_is_customized = ( $this->doing_ajax() || isset( $_POST['customized'] ) );
    380                 if ( is_admin() && ! $doing_ajax_or_is_customized ) {
    381                         auth_redirect();
    382                 } elseif ( $doing_ajax_or_is_customized && ! is_user_logged_in() ) {
    383                         $this->wp_die( 0, __( 'You must be logged in to complete this action.' ) );
    384                 }
    385 
    386                 show_admin_bar( false );
    387 
    388                 if ( ! current_user_can( 'customize' ) ) {
    389                         $this->wp_die( -1, __( 'Sorry, you are not allowed to customize this site.' ) );
    390                 }
    391 
    392                 $this->original_stylesheet = get_stylesheet();
    393 
    394                 $this->theme = wp_get_theme( isset( $_REQUEST['theme'] ) ? $_REQUEST['theme'] : null );
     462                global $pagenow;
     463
     464                // Check permissions for customize.php access since this method is called before customize.php can run any code,
     465                if ( 'customize.php' === $pagenow && ! current_user_can( 'customize' ) ) {
     466                        if ( ! is_user_logged_in() ) {
     467                                auth_redirect();
     468                        } else {
     469                                wp_die(
     470                                        '<h1>' . __( 'Cheatin&#8217; uh?' ) . '</h1>' .
     471                                        '<p>' . __( 'Sorry, you are not allowed to customize this site.' ) . '</p>',
     472                                        403
     473                                );
     474                        }
     475                        return;
     476                }
     477
     478                if ( ! preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $this->_changeset_uuid ) ) {
     479                        $this->wp_die( -1, __( 'Invalid changeset UUID' ) );
     480                }
     481
     482                /*
     483                 * If unauthenticated then require a valid changeset UUID to load the preview.
     484                 * In this way, the UUID serves as a secret key. If the messenger channel is present,
     485                 * then send unauthenticated code to prompt re-auth.
     486                 */
     487                if ( ! current_user_can( 'customize' ) && ! $this->changeset_post_id() ) {
     488                        $this->wp_die( $this->messenger_channel ? 0 : -1, __( 'Non-existent changeset UUID.' ) );
     489                }
     490
     491                if ( ! headers_sent() ) {
     492                        send_origin_headers();
     493                }
     494
     495                // Hide the admin bar if we're embedded in the customizer iframe.
     496                if ( $this->messenger_channel ) {
     497                        show_admin_bar( false );
     498                }
    395499
    396500                if ( $this->is_theme_active() ) {
     
    508612
    509613        /**
     614         * Get the changeset UUID.
     615         *
     616         * @since 4.7.0
     617         * @access public
     618         *
     619         * @return string UUID.
     620         */
     621        public function changeset_uuid() {
     622                return $this->_changeset_uuid;
     623        }
     624
     625        /**
    510626         * Get the theme being customized.
    511627         *
     
    604720                do_action( 'customize_register', $this );
    605721
    606                 if ( $this->is_preview() && ! is_admin() )
     722                /*
     723                 * Note that settings must be previewed here even outside the customizer preview
     724                 * and also in the customizer pane itself. This is to enable loading an existing
     725                 * changeset into the customizer. Previewing the settings only has to be prevented
     726                 * in the case of a customize_save action because then update_option()
     727                 * may short-circuit because it will detect that there are no changes to
     728                 * make.
     729                 */
     730                if ( ! $this->doing_ajax( 'customize_save' ) ) {
     731                        foreach ( $this->settings as $setting ) {
     732                                $setting->preview();
     733                        }
     734                }
     735
     736                if ( $this->is_preview() && ! is_admin() ) {
    607737                        $this->customize_preview_init();
     738                }
    608739        }
    609740
     
    615746         *
    616747         * @since 3.4.0
    617          *
    618          * @param $status
     748         * @deprecated 4.7.0
     749         *
     750         * @param int $status Status.
    619751         * @return int
    620752         */
    621753        public function wp_redirect_status( $status ) {
    622                 if ( $this->is_preview() && ! is_admin() )
     754                _deprecated_function( __FUNCTION__, '4.7.0' );
     755
     756                if ( $this->is_preview() && ! is_admin() ) {
    623757                        return 200;
     758                }
    624759
    625760                return $status;
     
    627762
    628763        /**
    629          * Parse the incoming $_POST['customized'] JSON data and store the unsanitized
    630          * settings for subsequent post_value() lookups.
     764         * Find the changeset post ID for a given changeset UUID.
     765         *
     766         * @since 4.7.0
     767         * @access public
     768         *
     769         * @param string $uuid Changeset UUID.
     770         * @return int|null Returns post ID on success and null on failure.
     771         */
     772        public function find_changeset_post_id( $uuid ) {
     773                $cache_group = 'customize_changeset_post';
     774                $changeset_post_id = wp_cache_get( $uuid, $cache_group );
     775                if ( $changeset_post_id && 'customize_changeset' === get_post_type( $changeset_post_id ) ) {
     776                        return $changeset_post_id;
     777                }
     778
     779                $changeset_post_query = new WP_Query( array(
     780                        'post_type' => 'customize_changeset',
     781                        'post_status' => get_post_stati(),
     782                        'name' => $uuid,
     783                        'number' => 1,
     784                        'no_found_rows' => true,
     785                        'cache_results' => true,
     786                        'update_post_meta_cache' => false,
     787                        'update_term_meta_cache' => false,
     788                ) );
     789                if ( ! empty( $changeset_post_query->posts ) ) {
     790                        // Note: 'fields'=>'ids' is not being used in order to cache the post object as it will be needed.
     791                        $changeset_post_id = $changeset_post_query->posts[0]->ID;
     792                        wp_cache_set( $this->_changeset_uuid, $changeset_post_id, $cache_group );
     793                        return $changeset_post_id;
     794                }
     795
     796                return null;
     797        }
     798
     799        /**
     800         * Get the changeset post id for the loaded changeset.
     801         *
     802         * @since 4.7.0
     803         * @access public
     804         *
     805         * @return int|null Post ID on success or null if there is no post yet saved.
     806         */
     807        public function changeset_post_id() {
     808                if ( ! isset( $this->_changeset_post_id ) ) {
     809                        $post_id = $this->find_changeset_post_id( $this->_changeset_uuid );
     810                        if ( ! $post_id ) {
     811                                $post_id = false;
     812                        }
     813                        $this->_changeset_post_id = $post_id;
     814                }
     815                if ( false === $this->_changeset_post_id ) {
     816                        return null;
     817                }
     818                return $this->_changeset_post_id;
     819        }
     820
     821        /**
     822         * Get the data stored in a changeset post.
     823         *
     824         * @since 4.7.0
     825         * @access protected
     826         *
     827         * @param int $post_id Changeset post ID.
     828         * @return array|WP_Error Changeset data or WP_Error on error.
     829         */
     830        protected function get_changeset_post_data( $post_id ) {
     831                if ( ! $post_id ) {
     832                        return new WP_Error( 'empty_post_id' );
     833                }
     834                $changeset_post = get_post( $post_id );
     835                if ( ! $changeset_post ) {
     836                        return new WP_Error( 'missing_post' );
     837                }
     838                if ( 'customize_changeset' !== $changeset_post->post_type ) {
     839                        return new WP_Error( 'wrong_post_type' );
     840                }
     841                $changeset_data = json_decode( $changeset_post->post_content, true );
     842                if ( function_exists( 'json_last_error' ) && json_last_error() ) {
     843                        return new WP_Error( 'json_parse_error', '', json_last_error() );
     844                }
     845                if ( ! is_array( $changeset_data ) ) {
     846                        return new WP_Error( 'expected_array' );
     847                }
     848                return $changeset_data;
     849        }
     850
     851        /**
     852         * Get changeset data.
     853         *
     854         * @since 4.7.0
     855         * @access public
     856         *
     857         * @return array Changeset data.
     858         */
     859        public function changeset_data() {
     860                if ( isset( $this->_changeset_data ) ) {
     861                        return $this->_changeset_data;
     862                }
     863                $changeset_post_id = $this->changeset_post_id();
     864                if ( ! $changeset_post_id ) {
     865                        $this->_changeset_data = array();
     866                } else {
     867                        $data = $this->get_changeset_post_data( $changeset_post_id );
     868                        if ( ! is_wp_error( $data ) ) {
     869                                $this->_changeset_data = $data;
     870                        } else {
     871                                $this->_changeset_data = array();
     872                        }
     873                }
     874                return $this->_changeset_data;
     875        }
     876
     877        /**
     878         * Get dirty pre-sanitized setting values in the current customized state.
     879         *
     880         * The returned array consists of a merge of three sources:
     881         * 1. If the theme is not currently active, then the base array is any stashed
     882         *    theme mods that were modified previously but never published.
     883         * 2. The values from the current changeset, if it exists.
     884         * 3. If the user can customize, the values parsed from the incoming
     885         *    `$_POST['customized']` JSON data.
     886         * 4. Any programmatically-set post values via `WP_Customize_Manager::set_post_value()`.
     887         *
     888         * The name "unsanitized_post_values" is a carry-over from when the customized
     889         * state was exclusively sourced from `$_POST['customized']`. Nevertheless,
     890         * the value returned will come from the current changeset post and from the
     891         * incoming post data.
    631892         *
    632893         * @since 4.1.1
    633          *
     894         * @since 4.7.0 Added $args param and merging with changeset values and stashed theme mods.
     895         *
     896         * @param array $args {
     897         *     Args.
     898         *
     899         *     @type bool $exclude_changeset Whether the changeset values should also be excluded. Defaults to false.
     900         *     @type bool $exclude_post_data Whether the post input values should also be excluded. Defaults to false when lacking the customize capability.
     901         * }
    634902         * @return array
    635903         */
    636         public function unsanitized_post_values() {
    637                 if ( ! isset( $this->_post_values ) ) {
    638                         if ( isset( $_POST['customized'] ) ) {
    639                                 $this->_post_values = json_decode( wp_unslash( $_POST['customized'] ), true );
    640                         }
    641                         if ( empty( $this->_post_values ) ) { // if not isset or if JSON error
    642                                 $this->_post_values = array();
    643                         }
    644                 }
    645                 if ( empty( $this->_post_values ) ) {
    646                         return array();
    647                 } else {
    648                         return $this->_post_values;
    649                 }
    650         }
    651 
    652         /**
    653          * Returns the sanitized value for a given setting from the request's POST data.
     904        public function unsanitized_post_values( $args = array() ) {
     905                $args = array_merge(
     906                        array(
     907                                'exclude_changeset' => false,
     908                                'exclude_post_data' => ! current_user_can( 'customize' ),
     909                        ),
     910                        $args
     911                );
     912
     913                $values = array();
     914
     915                // Let default values be from the stashed theme mods if doing a theme switch and if no changeset is present.
     916                if ( ! $this->is_theme_active() ) {
     917                        $stashed_theme_mods = get_option( 'customize_stashed_theme_mods' );
     918                        $stylesheet = $this->get_stylesheet();
     919                        if ( isset( $stashed_theme_mods[ $stylesheet ] ) ) {
     920                                $values = array_merge( $values, wp_list_pluck( $stashed_theme_mods[ $stylesheet ], 'value' ) );
     921                        }
     922                }
     923
     924                if ( ! $args['exclude_changeset'] ) {
     925                        foreach ( $this->changeset_data() as $setting_id => $setting_params ) {
     926                                if ( ! array_key_exists( 'value', $setting_params ) ) {
     927                                        continue;
     928                                }
     929                                if ( isset( $setting_params['type'] ) && 'theme_mod' === $setting_params['type'] ) {
     930
     931                                        // Ensure that theme mods values are only used if they were saved under the current theme.
     932                                        $namespace_pattern = '/^(?P<stylesheet>.+?)::(?P<setting_id>.+)$/';
     933                                        if ( preg_match( $namespace_pattern, $setting_id, $matches ) && $this->get_stylesheet() === $matches['stylesheet'] ) {
     934                                                $values[ $matches['setting_id'] ] = $setting_params['value'];
     935                                        }
     936                                } else {
     937                                        $values[ $setting_id ] = $setting_params['value'];
     938                                }
     939                        }
     940                }
     941
     942                if ( ! $args['exclude_post_data'] ) {
     943                        if ( ! isset( $this->_post_values ) ) {
     944                                if ( isset( $_POST['customized'] ) ) {
     945                                        $post_values = json_decode( wp_unslash( $_POST['customized'] ), true );
     946                                } else {
     947                                        $post_values = array();
     948                                }
     949                                if ( is_array( $post_values ) ) {
     950                                        $this->_post_values = $post_values;
     951                                } else {
     952                                        $this->_post_values = array();
     953                                }
     954                        }
     955                        $values = array_merge( $values, $this->_post_values );
     956                }
     957                return $values;
     958        }
     959
     960        /**
     961         * Returns the sanitized value for a given setting from the current customized state.
     962         *
     963         * The name "post_value" is a carry-over from when the customized state was exclusively
     964         * sourced from `$_POST['customized']`. Nevertheless, the value returned will come
     965         * from the current changeset post and from the incoming post data.
    654966         *
    655967         * @since 3.4.0
     
    685997
    686998        /**
    687          * Override a setting's (unsanitized) value as found in any incoming $_POST['customized'].
     999         * Override a setting's value in the current customized state.
     1000         *
     1001         * The name "post_value" is a carry-over from when the customized state was
     1002         * exclusively sourced from `$_POST['customized']`.
    6881003         *
    6891004         * @since 4.2.0
     
    6941009         */
    6951010        public function set_post_value( $setting_id, $value ) {
    696                 $this->unsanitized_post_values();
     1011                $this->unsanitized_post_values(); // Populate _post_values from $_POST['customized'].
    6971012                $this->_post_values[ $setting_id ] = $value;
    6981013
     
    7341049         */
    7351050        public function customize_preview_init() {
    736                 $this->nonce_tick = check_ajax_referer( 'preview-customize_' . $this->get_stylesheet(), 'nonce' );
     1051
     1052                /*
     1053                 * Now that Customizer previews are loaded into iframes via GET requests
     1054                 * and natural URLs with transaction UUIDs added, we need to ensure that
     1055                 * the responses are never cached by proxies. In practice, this will not
     1056                 * be needed if the user is logged-in anyway. But if anonymous access is
     1057                 * allowed then the auth cookies would not be sent and WordPress would
     1058                 * not send no-cache headers by default.
     1059                 */
     1060                if ( ! headers_sent() ) {
     1061                        nocache_headers();
     1062                        header( 'X-Robots: noindex, nofollow, noarchive' );
     1063                }
     1064                add_action( 'wp_head', 'wp_no_robots' );
     1065                add_filter( 'wp_headers', array( $this, 'filter_iframe_security_headers' ) );
     1066
     1067                /*
     1068                 * If preview is being served inside the customizer preview iframe, and
     1069                 * if the user doesn't have customize capability, then it is assumed
     1070                 * that the user's session has expired and they need to re-authenticate.
     1071                 */
     1072                if ( $this->messenger_channel && ! current_user_can( 'customize' ) ) {
     1073                        $this->wp_die( -1, __( 'Unauthorized. You may remove the customize_messenger_channel param to preview as frontend.' ) );
     1074                        return;
     1075                }
    7371076
    7381077                $this->prepare_controls();
    7391078
     1079                add_filter( 'wp_redirect', array( $this, 'add_state_query_params' ) );
     1080
    7401081                wp_enqueue_script( 'customize-preview' );
    741                 add_action( 'wp', array( $this, 'customize_preview_override_404_status' ) );
    742                 add_action( 'wp_head', array( $this, 'customize_preview_base' ) );
    7431082                add_action( 'wp_head', array( $this, 'customize_preview_loading_style' ) );
    7441083                add_action( 'wp_footer', array( $this, 'customize_preview_settings' ), 20 );
    745                 add_action( 'shutdown', array( $this, 'customize_preview_signature' ), 1000 );
    746                 add_filter( 'wp_die_handler', array( $this, 'remove_preview_signature' ) );
    747 
    748                 foreach ( $this->settings as $setting ) {
    749                         $setting->preview();
    750                 }
    7511084
    7521085                /**
     
    7621095
    7631096        /**
     1097         * Filter the X-Frame-Options and Content-Security-Policy headers to ensure frontend can load in customizer.
     1098         *
     1099         * @since 4.7.0
     1100         * @access public
     1101         *
     1102         * @param array $headers Headers.
     1103         * @return array Headers.
     1104         */
     1105        public function filter_iframe_security_headers( $headers ) {
     1106                $customize_url = admin_url( 'customize.php' );
     1107                $headers['X-Frame-Options'] = 'ALLOW-FROM ' . $customize_url;
     1108                $headers['Content-Security-Policy'] = 'frame-ancestors ' . preg_replace( '#^(\w+://[^/]+).+?$#', '$1', $customize_url );
     1109                return $headers;
     1110        }
     1111
     1112        /**
     1113         * Add customize state query params to a given URL if preview is allowed.
     1114         *
     1115         * @since 4.7.0
     1116         * @access public
     1117         * @see wp_redirect()
     1118         * @see WP_Customize_Manager::get_allowed_url()
     1119         *
     1120         * @param string $url URL.
     1121         * @return string URL.
     1122         */
     1123        public function add_state_query_params( $url ) {
     1124                $parsed_original_url = wp_parse_url( $url );
     1125                $is_allowed = false;
     1126                foreach ( $this->get_allowed_urls() as $allowed_url ) {
     1127                        $parsed_allowed_url = wp_parse_url( $allowed_url );
     1128                        $is_allowed = (
     1129                                $parsed_allowed_url['scheme'] === $parsed_original_url['scheme']
     1130                                &&
     1131                                $parsed_allowed_url['host'] === $parsed_original_url['host']
     1132                                &&
     1133                                0 === strpos( $parsed_original_url['path'], $parsed_allowed_url['path'] )
     1134                        );
     1135                        if ( $is_allowed ) {
     1136                                break;
     1137                        }
     1138                }
     1139
     1140                if ( $is_allowed ) {
     1141                        $query_params = array(
     1142                                'customize_changeset_uuid' => $this->changeset_uuid(),
     1143                        );
     1144                        if ( ! $this->is_theme_active() ) {
     1145                                $query_params['customize_theme'] = $this->get_stylesheet();
     1146                        }
     1147                        if ( $this->messenger_channel ) {
     1148                                $query_params['customize_messenger_channel'] = $this->messenger_channel;
     1149                        }
     1150                        $url = add_query_arg( $query_params, $url );
     1151                }
     1152
     1153                return $url;
     1154        }
     1155
     1156        /**
    7641157         * Prevent sending a 404 status when returning the response for the customize
    7651158         * preview, since it causes the jQuery Ajax to fail. Send 200 instead.
    7661159         *
    7671160         * @since 4.0.0
     1161         * @deprecated 4.7.0
    7681162         * @access public
    7691163         */
    7701164        public function customize_preview_override_404_status() {
    771                 if ( is_404() ) {
    772                         status_header( 200 );
    773                 }
     1165                _deprecated_function( __METHOD__, '4.7.0' );
    7741166        }
    7751167
     
    7781170         *
    7791171         * @since 3.4.0
     1172         * @deprecated 4.7.0
    7801173         */
    7811174        public function customize_preview_base() {
    782                 ?><base href="<?php echo home_url( '/' ); ?>" /><?php
     1175                _deprecated_function( __METHOD__, '4.7.0' );
    7831176        }
    7841177
     
    8101203                                pointer-events: none !important;
    8111204                        }
     1205                        form.customize-unpreviewable,
     1206                        form.customize-unpreviewable input,
     1207                        form.customize-unpreviewable select,
     1208                        form.customize-unpreviewable button,
     1209                        a.customize-unpreviewable,
     1210                        area.customize-unpreviewable {
     1211                                cursor: not-allowed !important;
     1212                        }
    8121213                </style><?php
    8131214        }
     
    8191220         */
    8201221        public function customize_preview_settings() {
    821                 $setting_validities = $this->validate_setting_values( $this->unsanitized_post_values() );
     1222                $post_values = $this->unsanitized_post_values( array( 'exclude_changeset' => true ) );
     1223                $setting_validities = $this->validate_setting_values( $post_values );
    8221224                $exported_setting_validities = array_map( array( $this, 'prepare_setting_validity_for_js' ), $setting_validities );
    8231225
     1226                // Note that the REQUEST_URI is not passed into home_url() since this breaks subdirectory installs.
     1227                $self_url = empty( $_SERVER['REQUEST_URI'] ) ? home_url( '/' ) : esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
     1228                $state_query_params = array(
     1229                        'customize_theme',
     1230                        'customize_changeset_uuid',
     1231                        'customize_messenger_channel',
     1232                );
     1233                $self_url = remove_query_arg( $state_query_params, $self_url );
     1234
     1235                $allowed_urls = $this->get_allowed_urls();
     1236                $allowed_hosts = array();
     1237                foreach ( $allowed_urls as $allowed_url ) {
     1238                        $parsed = wp_parse_url( $allowed_url );
     1239                        if ( empty( $parsed['host'] ) ) {
     1240                                continue;
     1241                        }
     1242                        $host = $parsed['host'];
     1243                        if ( ! empty( $parsed['port'] ) ) {
     1244                                $host .= ':' . $parsed['port'];
     1245                        }
     1246                        $allowed_hosts[] = $host;
     1247                }
    8241248                $settings = array(
     1249                        'changeset' => array(
     1250                                'uuid' => $this->_changeset_uuid,
     1251                        ),
     1252                        'timeouts' => array(
     1253                                'selectiveRefresh' => 250,
     1254                                'keepAliveSend' => 1000,
     1255                        ),
    8251256                        'theme' => array(
    8261257                                'stylesheet' => $this->get_stylesheet(),
     
    8281259                        ),
    8291260                        'url' => array(
    830                                 'self' => empty( $_SERVER['REQUEST_URI'] ) ? home_url( '/' ) : esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ),
     1261                                'self' => $self_url,
     1262                                'allowed' => array_map( 'esc_url_raw', $this->get_allowed_urls() ),
     1263                                'allowedHosts' => array_unique( $allowed_hosts ),
     1264                                'isCrossDomain' => $this->is_cross_domain(),
    8311265                        ),
    832                         'channel' => wp_unslash( $_POST['customize_messenger_channel'] ),
     1266                        'channel' => $this->messenger_channel,
    8331267                        'activePanels' => array(),
    8341268                        'activeSections' => array(),
    8351269                        'activeControls' => array(),
    8361270                        'settingValidities' => $exported_setting_validities,
    837                         'nonce' => $this->get_nonces(),
     1271                        'nonce' => current_user_can( 'customize' ) ? $this->get_nonces() : array(),
    8381272                        'l10n' => array(
    8391273                                'shiftClickToEdit' => __( 'Shift-click to edit this element.' ),
     1274                                'linkUnpreviewable' => __( 'This link is not live-previewable.' ),
     1275                                'formUnpreviewable' => __( 'This form is not live-previewable.' ),
    8401276                        ),
    841                         '_dirty' => array_keys( $this->unsanitized_post_values() ),
     1277                        '_dirty' => array_keys( $post_values ),
    8421278                );
    8431279
     
    8931329         *
    8941330         * @since 3.4.0
     1331         * @deprecated 4.7.0
    8951332         */
    8961333        public function customize_preview_signature() {
    897                 echo 'WP_CUSTOMIZER_SIGNATURE';
     1334                _deprecated_function( __METHOD__, '4.7.0' );
    8981335        }
    8991336
     
    9021339         *
    9031340         * @since 3.4.0
     1341         * @deprecated 4.7.0
    9041342         *
    9051343         * @param mixed $return Value passed through for {@see 'wp_die_handler'} filter.
     
    9071345         */
    9081346        public function remove_preview_signature( $return = null ) {
    909                 remove_action( 'shutdown', array( $this, 'customize_preview_signature' ), 1000 );
     1347                _deprecated_function( __METHOD__, '4.7.0' );
    9101348
    9111349                return $return;
     
    9941432         *
    9951433         * @param array $setting_values Mapping of setting IDs to values to validate and sanitize.
     1434         * @param array $options {
     1435         *     Options.
     1436         *
     1437         *     @type bool $validate_existence  Whether a setting's existence will be checked.
     1438         *     @type bool $validate_capability Whether the setting capability will be checked.
     1439         * }
    9961440         * @return array Mapping of setting IDs to return value of validate method calls, either `true` or `WP_Error`.
    9971441         */
    998         public function validate_setting_values( $setting_values ) {
     1442        public function validate_setting_values( $setting_values, $options = array() ) {
     1443                $options = wp_parse_args( $options, array(
     1444                        'validate_capability' => false,
     1445                        'validate_existence' => false,
     1446                ) );
     1447
    9991448                $validities = array();
    10001449                foreach ( $setting_values as $setting_id => $unsanitized_value ) {
    10011450                        $setting = $this->get_setting( $setting_id );
    1002                         if ( ! $setting || is_null( $unsanitized_value ) ) {
     1451                        if ( ! $setting ) {
     1452                                if ( $options['validate_existence'] ) {
     1453                                        $validities[ $setting_id ] = new WP_Error( 'unrecognized', __( 'Setting does not exist or is unrecognized.' ) );
     1454                                }
    10031455                                continue;
    10041456                        }
    1005                         $validity = $setting->validate( $unsanitized_value );
     1457                        if ( is_null( $unsanitized_value ) ) {
     1458                                continue;
     1459                        }
     1460                        if ( $options['validate_capability'] && ! current_user_can( $setting->capability ) ) {
     1461                                $validity = new WP_Error( 'unauthorized', __( 'Unauthorized to modify setting due to capability.' ) );
     1462                        } else {
     1463                                $validity = $setting->validate( $unsanitized_value );
     1464                        }
    10061465                        if ( ! is_wp_error( $validity ) ) {
    10071466                                /** This filter is documented in wp-includes/class-wp-customize-setting.php */
     
    10571516
    10581517        /**
    1059          * Switch the theme and trigger the save() method on each setting.
    1060          *
    1061          * @since 3.4.0
     1518         * Handle customize_save WP Ajax request to save/update a changeset.
     1519         *
     1520         * @since 3.4.0
     1521         * @since 4.7.0 The semantics of this method have changed to update a changeset, optionally to also change the status and other attributes.
    10621522         */
    10631523        public function save() {
     1524                if ( ! is_user_logged_in() ) {
     1525                        wp_send_json_error( 'unauthenticated' );
     1526                }
     1527
    10641528                if ( ! $this->is_preview() ) {
    10651529                        wp_send_json_error( 'not_preview' );
     
    10711535                }
    10721536
     1537                $changeset_post_id = $this->changeset_post_id();
     1538                if ( $changeset_post_id && in_array( get_post_status( $changeset_post_id ), array( 'publish', 'trash' ) ) ) {
     1539                        wp_send_json_error( 'changeset_already_published' );
     1540                }
     1541
     1542                if ( empty( $changeset_post_id ) ) {
     1543                        if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->create_posts ) ) {
     1544                                wp_send_json_error( 'cannot_create_changeset_post' );
     1545                        }
     1546                } else {
     1547                        if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->edit_post, $changeset_post_id ) ) {
     1548                                wp_send_json_error( 'cannot_edit_changeset_post' );
     1549                        }
     1550                }
     1551
     1552                if ( ! empty( $_POST['customize_changeset_data'] ) ) {
     1553                        $input_changeset_data = json_decode( wp_unslash( $_POST['customize_changeset_data'] ), true );
     1554                        if ( ! is_array( $input_changeset_data ) ) {
     1555                                wp_send_json_error( 'invalid_customize_changeset_data' );
     1556                        }
     1557                } else {
     1558                        $input_changeset_data = array();
     1559                }
     1560
     1561                // Validate title.
     1562                $changeset_title = null;
     1563                if ( isset( $_POST['customize_changeset_title'] ) ) {
     1564                        $changeset_title = sanitize_text_field( wp_unslash( $_POST['customize_changeset_title'] ) );
     1565                }
     1566
     1567                // Validate changeset status param.
     1568                $is_publish = null;
     1569                $changeset_status = null;
     1570                if ( isset( $_POST['customize_changeset_status'] ) ) {
     1571                        $changeset_status = wp_unslash( $_POST['customize_changeset_status'] );
     1572                        if ( ! get_post_status_object( $changeset_status ) || ! in_array( $changeset_status, array( 'draft', 'pending', 'publish', 'future' ), true ) ) {
     1573                                wp_send_json_error( 'bad_customize_changeset_status', 400 );
     1574                        }
     1575                        $is_publish = ( 'publish' === $changeset_status || 'future' === $changeset_status );
     1576                        if ( $is_publish ) {
     1577                                if ( ! current_user_can( get_post_type_object( 'customize_changeset' )->cap->publish_posts ) ) {
     1578                                        wp_send_json_error( 'changeset_publish_unauthorized', 403 );
     1579                                }
     1580                                if ( false === has_action( 'transition_post_status', '_wp_customize_publish_changeset' ) ) {
     1581                                        wp_send_json_error( 'missing_publish_callback', 500 );
     1582                                }
     1583                        }
     1584                }
     1585
     1586                /*
     1587                 * Validate changeset date param. Date is assumed to be in local time for
     1588                 * the WP if in MySQL format (YYYY-MM-DD HH:MM:SS). Otherwise, the date
     1589                 * is parsed with strtotime() so that ISO date format may be supplied
     1590                 * or a string like "+10 minutes".
     1591                 */
     1592                $changeset_date_gmt = null;
     1593                if ( isset( $_POST['customize_changeset_date'] ) ) {
     1594                        $changeset_date = wp_unslash( $_POST['customize_changeset_date'] );
     1595                        if ( preg_match( '/^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d$/', $changeset_date ) ) {
     1596                                $mm = substr( $changeset_date, 5, 2 );
     1597                                $jj = substr( $changeset_date, 8, 2 );
     1598                                $aa = substr( $changeset_date, 0, 4 );
     1599                                $valid_date = wp_checkdate( $mm, $jj, $aa, $changeset_date );
     1600                                if ( ! $valid_date ) {
     1601                                        wp_send_json_error( 'bad_customize_changeset_date', 400 );
     1602                                }
     1603                                $changeset_date_gmt = get_gmt_from_date( $changeset_date );
     1604                        } else {
     1605                                $timestamp = strtotime( $changeset_date );
     1606                                if ( ! $timestamp ) {
     1607                                        wp_send_json_error( 'bad_customize_changeset_date', 400 );
     1608                                }
     1609                                $changeset_date_gmt = gmdate( 'Y-m-d H:i:s', $timestamp );
     1610                        }
     1611                        $now = gmdate( 'Y-m-d H:i:59' );
     1612
     1613                        $is_future_dated = ( mysql2date( 'U', $changeset_date_gmt, false ) > mysql2date( 'U', $now, false ) );
     1614                        if ( ! $is_future_dated ) {
     1615                                wp_send_json_error( 'not_future_date', 400 ); // Only future dates are allowed.
     1616                        }
     1617
     1618                        if ( ! $this->is_theme_active() && ( 'future' === $changeset_status || $is_future_dated ) ) {
     1619                                wp_send_json_error( 'cannot_schedule_theme_switches', 400 ); // This should be allowed in the future, when theme is a regular setting.
     1620                        }
     1621                        $will_remain_auto_draft = ( ! $changeset_status && ( ! $changeset_post_id || 'auto-draft' === get_post_status( $changeset_post_id ) ) );
     1622                        if ( $changeset_date && $will_remain_auto_draft ) {
     1623                                wp_send_json_error( 'cannot_supply_date_for_auto_draft_changeset', 400 );
     1624                        }
     1625                }
     1626
     1627                $r = $this->save_changeset_post( array(
     1628                        'status' => $changeset_status,
     1629                        'title' => $changeset_title,
     1630                        'date_gmt' => $changeset_date_gmt,
     1631                        'data' => $input_changeset_data,
     1632                ) );
     1633                if ( is_wp_error( $r ) ) {
     1634                        $response = $r->get_error_data();
     1635                } else {
     1636                        $response = $r;
     1637
     1638                        // Note that if the changeset status was publish, then it will get set to trash if revisions are not supported.
     1639                        $response['changeset_status'] = get_post_status( $this->changeset_post_id() );
     1640                        if ( $is_publish && 'trash' === $response['changeset_status'] ) {
     1641                                $response['changeset_status'] = 'publish';
     1642                        }
     1643
     1644                        if ( 'publish' === $response['changeset_status'] ) {
     1645                                $response['next_changeset_uuid'] = wp_generate_uuid4();
     1646                        }
     1647                }
     1648
     1649                if ( isset( $response['setting_validities'] ) ) {
     1650                        $response['setting_validities'] = array_map( array( $this, 'prepare_setting_validity_for_js' ), $response['setting_validities'] );
     1651                }
     1652
     1653                /**
     1654                 * Filters response data for a successful customize_save Ajax request.
     1655                 *
     1656                 * This filter does not apply if there was a nonce or authentication failure.
     1657                 *
     1658                 * @since 4.2.0
     1659                 *
     1660                 * @param array                $response Additional information passed back to the 'saved'
     1661                 *                                       event on `wp.customize`.
     1662                 * @param WP_Customize_Manager $this     WP_Customize_Manager instance.
     1663                 */
     1664                $response = apply_filters( 'customize_save_response', $response, $this );
     1665
     1666                if ( is_wp_error( $r ) ) {
     1667                        wp_send_json_error( $response );
     1668                } else {
     1669                        wp_send_json_success( $response );
     1670                }
     1671        }
     1672
     1673        /**
     1674         * Save the post for the loaded changeset.
     1675         *
     1676         * @since 4.7.0
     1677         * @access public
     1678         *
     1679         * @param array $args {
     1680         *     Args for changeset post.
     1681         *
     1682         *     @type array  $data     Optional additional changeset data. Values will be merged on top of any existing post values.
     1683         *     @type string $status   Post status. Optional. If supplied, the save will be transactional and a post revision will be allowed.
     1684         *     @type string $title    Post title. Optional.
     1685         *     @type string $date_gmt Date in GMT. Optional.
     1686         * }
     1687         *
     1688         * @return array|WP_Error Returns array on success and WP_Error with array data on error.
     1689         */
     1690        function save_changeset_post( $args = array() ) {
     1691
     1692                $args = array_merge(
     1693                        array(
     1694                                'status' => null,
     1695                                'title' => null,
     1696                                'data' => array(),
     1697                                'date_gmt' => null,
     1698                        ),
     1699                        $args
     1700                );
     1701
     1702                $changeset_post_id = $this->changeset_post_id();
     1703
     1704                // The request was made via wp.customize.previewer.save().
     1705                $update_transactionally = (bool) $args['status'];
     1706                $allow_revision = (bool) $args['status'];
     1707
     1708                // Amend post values with any supplied data.
     1709                foreach ( $args['data'] as $setting_id => $setting_params ) {
     1710                        if ( array_key_exists( 'value', $setting_params ) ) {
     1711                                $this->set_post_value( $setting_id, $setting_params['value'] ); // Add to post values so that they can be validated and sanitized.
     1712                        }
     1713                }
     1714
     1715                // Note that in addition to post data, this will include any stashed theme mods.
     1716                $post_values = $this->unsanitized_post_values( array(
     1717                        'exclude_changeset' => true,
     1718                        'exclude_post_data' => false,
     1719                ) );
     1720                $this->add_dynamic_settings( array_keys( $post_values ) ); // Ensure settings get created even if they lack an input value.
     1721
    10731722                /**
    10741723                 * Fires before save validation happens.
     
    10851734
    10861735                // Validate settings.
    1087                 $setting_validities = $this->validate_setting_values( $this->unsanitized_post_values() );
     1736                $setting_validities = $this->validate_setting_values( $post_values, array(
     1737                        'validate_capability' => true,
     1738                        'validate_existence' => true,
     1739                ) );
    10881740                $invalid_setting_count = count( array_filter( $setting_validities, 'is_wp_error' ) );
    1089                 $exported_setting_validities = array_map( array( $this, 'prepare_setting_validity_for_js' ), $setting_validities );
    1090                 if ( $invalid_setting_count > 0 ) {
     1741
     1742                /*
     1743                 * Short-circuit if there are invalid settings the update is transactional.
     1744                 * A changeset update is transactional when a status is supplied in the request.
     1745                 */
     1746                if ( $update_transactionally && $invalid_setting_count > 0 ) {
    10911747                        $response = array(
    1092                                 'setting_validities' => $exported_setting_validities,
     1748                                'setting_validities' => $setting_validities,
    10931749                                'message' => sprintf( _n( 'There is %s invalid setting.', 'There are %s invalid settings.', $invalid_setting_count ), number_format_i18n( $invalid_setting_count ) ),
    10941750                        );
    1095 
    1096                         /** This filter is documented in wp-includes/class-wp-customize-manager.php */
    1097                         $response = apply_filters( 'customize_save_response', $response, $this );
    1098                         wp_send_json_error( $response );
    1099                 }
    1100 
    1101                 // Do we have to switch themes?
    1102                 if ( ! $this->is_theme_active() ) {
    1103                         // Temporarily stop previewing the theme to allow switch_themes()
    1104                         // to operate properly.
     1751                        return new WP_Error( 'transaction_fail', '', $response );
     1752                }
     1753
     1754                $response = array(
     1755                        'setting_validities' => $setting_validities,
     1756                );
     1757
     1758                // Obtain/merge data for changeset.
     1759                $original_changeset_data = $this->get_changeset_post_data( $changeset_post_id );
     1760                $data = $original_changeset_data;
     1761                if ( is_wp_error( $data ) ) {
     1762                        $data = array();
     1763                }
     1764
     1765                // Ensure that all post values are included in the changeset data.
     1766                foreach ( $post_values as $setting_id => $post_value ) {
     1767                        if ( ! isset( $args['data'][ $setting_id ] ) ) {
     1768                                $args['data'][ $setting_id ] = array();
     1769                        }
     1770                        if ( ! isset( $args['data'][ $setting_id ]['value'] ) ) {
     1771                                $args['data'][ $setting_id ]['value'] = $post_value;
     1772                        }
     1773                }
     1774
     1775                foreach ( $args['data'] as $setting_id => $setting_params ) {
     1776                        $setting = $this->get_setting( $setting_id );
     1777                        if ( ! $setting || ! $setting->check_capabilities() ) {
     1778                                continue;
     1779                        }
     1780
     1781                        // Skip updating changeset for invalid setting values.
     1782                        if ( isset( $setting_validities[ $setting_id ] ) && is_wp_error( $setting_validities[ $setting_id ] ) ) {
     1783                                continue;
     1784                        }
     1785
     1786                        $changeset_setting_id = $setting_id;
     1787                        if ( 'theme_mod' === $setting->type ) {
     1788                                $changeset_setting_id = sprintf( '%s::%s', $this->get_stylesheet(), $setting_id );
     1789                        }
     1790
     1791                        if ( null === $setting_params ) {
     1792                                // Remove setting from changeset entirely.
     1793                                unset( $data[ $changeset_setting_id ] );
     1794                        } else {
     1795                                // Merge any additional setting params that have been supplied with the existing params.
     1796                                if ( ! isset( $data[ $changeset_setting_id ] ) ) {
     1797                                        $data[ $changeset_setting_id ] = array();
     1798                                }
     1799                                $data[ $changeset_setting_id ] = array_merge(
     1800                                        $data[ $changeset_setting_id ],
     1801                                        $setting_params,
     1802                                        array( 'type' => $setting->type )
     1803                                );
     1804                        }
     1805                }
     1806
     1807                $filter_context = array(
     1808                        'uuid' => $this->changeset_uuid(),
     1809                        'title' => $args['title'],
     1810                        'status' => $args['status'],
     1811                        'date_gmt' => $args['date_gmt'],
     1812                        'post_id' => $changeset_post_id,
     1813                        'previous_data' => is_wp_error( $original_changeset_data ) ? array() : $original_changeset_data,
     1814                        'manager' => $this,
     1815                );
     1816
     1817                /**
     1818                 * Filters the settings' data that will be persisted into the changeset.
     1819                 *
     1820                 * Plugins may amend additional data (such as additional meta for settings) into the changeset with this filter.
     1821                 *
     1822                 * @since 4.7.0
     1823                 *
     1824                 * @param array $data Updated changeset data, mapping setting IDs to arrays containing a $value item and optionally other metadata.
     1825                 * @param array $context {
     1826                 *     Filter context.
     1827                 *
     1828                 *     @type string               $uuid          Changeset UUID.
     1829                 *     @type string               $title         Requested title for the changeset post.
     1830                 *     @type string               $status        Requested status for the changeset post.
     1831                 *     @type string               $date_gmt      Requested date for the changeset post in MySQL format and GMT timezone.
     1832                 *     @type int|false            $post_id       Post ID for the changeset, or false if it doesn't exist yet.
     1833                 *     @type array                $previous_data Previous data contained in the changeset.
     1834                 *     @type WP_Customize_Manager $manager       Manager instance.
     1835                 * }
     1836                 */
     1837                $data = apply_filters( 'customize_changeset_save_data', $data, $filter_context );
     1838
     1839                // Switch theme if publishing changes now.
     1840                if ( 'publish' === $args['status'] && ! $this->is_theme_active() ) {
     1841                        // Temporarily stop previewing the theme to allow switch_themes() to operate properly.
    11051842                        $this->stop_previewing_theme();
    11061843                        switch_theme( $this->get_stylesheet() );
     
    11091846                }
    11101847
     1848                // Gather the data for wp_insert_post()/wp_update_post().
     1849                $json_options = 0;
     1850                if ( defined( 'JSON_UNESCAPED_SLASHES' ) ) {
     1851                        $json_options |= JSON_UNESCAPED_SLASHES; // Introduced in PHP 5.4. This is only to improve readability as slashes needn't be escaped in storage.
     1852                }
     1853                $json_options |= JSON_PRETTY_PRINT; // Also introduced in PHP 5.4, but WP defines constant for back compat. See WP Trac #30139.
     1854                $post_array = array(
     1855                        'post_content' => wp_json_encode( $data, $json_options ),
     1856                );
     1857                if ( $args['title'] ) {
     1858                        $post_array['post_title'] = $args['title'];
     1859                }
     1860                if ( $changeset_post_id ) {
     1861                        $post_array['ID'] = $changeset_post_id;
     1862                } else {
     1863                        $post_array['post_type'] = 'customize_changeset';
     1864                        $post_array['post_name'] = $this->changeset_uuid();
     1865                        $post_array['post_status'] = 'auto-draft';
     1866                }
     1867                if ( $args['status'] ) {
     1868                        $post_array['post_status'] = $args['status'];
     1869                }
     1870                if ( $args['date_gmt'] ) {
     1871                        $post_array['post_date_gmt'] = $args['date_gmt'];
     1872                        $post_array['post_date'] = get_date_from_gmt( $args['date_gmt'] );
     1873                }
     1874
     1875                $this->store_changeset_revision = $allow_revision;
     1876                add_filter( 'wp_save_post_revision_post_has_changed', array( $this, '_filter_revision_post_has_changed' ), 5, 3 );
     1877
     1878                // Update the changeset post. The publish_customize_changeset action will cause the settings in the changeset to be saved via WP_Customize_Setting::save().
     1879                $has_kses = ( false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ) );
     1880                if ( $has_kses ) {
     1881                        kses_remove_filters(); // Prevent KSES from corrupting JSON in post_content.
     1882                }
     1883
     1884                // Note that updating a post with publish status will trigger WP_Customize_Manager::publish_changeset_values().
     1885                if ( $changeset_post_id ) {
     1886                        $post_array['edit_date'] = true; // Prevent date clearing.
     1887                        $r = wp_update_post( wp_slash( $post_array ), true );
     1888                } else {
     1889                        $r = wp_insert_post( wp_slash( $post_array ), true );
     1890                        if ( ! is_wp_error( $r ) ) {
     1891                                $this->_changeset_post_id = $r; // Update cached post ID for the loaded changeset.
     1892                        }
     1893                }
     1894                if ( $has_kses ) {
     1895                        kses_init_filters();
     1896                }
     1897                $this->_changeset_data = null; // Reset so WP_Customize_Manager::changeset_data() will re-populate with updated contents.
     1898
     1899                remove_filter( 'wp_save_post_revision_post_has_changed', array( $this, '_filter_revision_post_has_changed' ) );
     1900
     1901                if ( is_wp_error( $r ) ) {
     1902                        $response['changeset_post_save_failure'] = $r->get_error_code();
     1903                        return new WP_Error( 'changeset_post_save_failure', '', $response );
     1904                }
     1905
     1906                return $response;
     1907        }
     1908
     1909        /**
     1910         * Whether a changeset revision should be made.
     1911         *
     1912         * @since 4.7.0
     1913         * @access private
     1914         * @var bool
     1915         */
     1916        protected $store_changeset_revision;
     1917
     1918        /**
     1919         * Filters whether a changeset has changed to create a new revision.
     1920         *
     1921         * Note that this will not be called while a changeset post remains in auto-draft status.
     1922         *
     1923         * @since 4.7.0
     1924         * @access private
     1925         *
     1926         * @param bool    $post_has_changed Whether the post has changed.
     1927         * @param WP_Post $last_revision    The last revision post object.
     1928         * @param WP_Post $post             The post object.
     1929         *
     1930         * @return bool Whether a revision should be made.
     1931         */
     1932        public function _filter_revision_post_has_changed( $post_has_changed, $last_revision, $post ) {
     1933                unset( $last_revision );
     1934                if ( 'customize_changeset' === $post->post_type ) {
     1935                        $post_has_changed = $this->store_changeset_revision;
     1936                }
     1937                return $post_has_changed;
     1938        }
     1939
     1940        /**
     1941         * Publish changeset values.
     1942         *
     1943         * This will the values contained in a changeset, even changesets that do not
     1944         * correspond to current manager instance. This is called by
     1945         * `_wp_customize_publish_changeset()` when a customize_changeset post is
     1946         * transitioned to the `publish` status. As such, this method should not be
     1947         * called directly and instead `wp_publish_post()` should be used.
     1948         *
     1949         * Please note that if the settings in the changeset are for a non-activated
     1950         * theme, the theme must first be switched to (via `switch_theme()`) before
     1951         * invoking this method.
     1952         *
     1953         * @since 4.7.0
     1954         * @access private
     1955         * @see _wp_customize_publish_changeset()
     1956         *
     1957         * @param int $changeset_post_id ID for customize_changeset post. Defaults to the changeset for the current manager instance.
     1958         * @return true|WP_Error True or error info.
     1959         */
     1960        public function _publish_changeset_values( $changeset_post_id ) {
     1961                $publishing_changeset_data = $this->get_changeset_post_data( $changeset_post_id );
     1962                if ( is_wp_error( $publishing_changeset_data ) ) {
     1963                        return $publishing_changeset_data;
     1964                }
     1965
     1966                $changeset_post = get_post( $changeset_post_id );
     1967
     1968                /*
     1969                 * Temporarily override the changeset context so that it will be read
     1970                 * in calls to unsanitized_post_values() and so that it will be available
     1971                 * on the $wp_customize object passed to hooks during the save logic.
     1972                 */
     1973                $previous_changeset_post_id = $this->_changeset_post_id;
     1974                $this->_changeset_post_id   = $changeset_post_id;
     1975                $previous_changeset_uuid    = $this->_changeset_uuid;
     1976                $this->_changeset_uuid      = $changeset_post->post_name;
     1977                $previous_changeset_data    = $this->_changeset_data;
     1978                $this->_changeset_data      = $publishing_changeset_data;
     1979
     1980                // Ensure that other theme mods are stashed.
     1981                $other_theme_mod_settings = array();
     1982                if ( did_action( 'switch_theme' ) ) {
     1983                        $namespace_pattern = '/^(?P<stylesheet>.+?)::(?P<setting_id>.+)$/';
     1984                        $matches = array();
     1985                        foreach ( $this->_changeset_data as $raw_setting_id => $setting_params ) {
     1986                                $is_other_theme_mod = (
     1987                                        isset( $setting_params['value'] )
     1988                                        &&
     1989                                        isset( $setting_params['type'] )
     1990                                        &&
     1991                                        'theme_mod' === $setting_params['type']
     1992                                        &&
     1993                                        preg_match( $namespace_pattern, $raw_setting_id, $matches )
     1994                                        &&
     1995                                        $this->get_stylesheet() !== $matches['stylesheet']
     1996                                );
     1997                                if ( $is_other_theme_mod ) {
     1998                                        if ( ! isset( $other_theme_mod_settings[ $matches['stylesheet'] ] ) ) {
     1999                                                $other_theme_mod_settings[ $matches['stylesheet'] ] = array();
     2000                                        }
     2001                                        $other_theme_mod_settings[ $matches['stylesheet'] ][ $matches['setting_id'] ] = $setting_params;
     2002                                }
     2003                        }
     2004                }
     2005
     2006                $changeset_setting_values = $this->unsanitized_post_values( array(
     2007                        'exclude_post_data' => true,
     2008                        'exclude_changeset' => false,
     2009                ) );
     2010                $changeset_setting_ids = array_keys( $changeset_setting_values );
     2011                $this->add_dynamic_settings( $changeset_setting_ids );
     2012
    11112013                /**
    11122014                 * Fires once the theme has switched in the Customizer, but before settings
     
    11152017                 * @since 3.4.0
    11162018                 *
    1117                  * @param WP_Customize_Manager $this WP_Customize_Manager instance.
     2019                 * @param WP_Customize_Manager $manager WP_Customize_Manager instance.
    11182020                 */
    11192021                do_action( 'customize_save', $this );
    11202022
    1121                 foreach ( $this->settings as $setting ) {
    1122                         $setting->save();
     2023                /*
     2024                 * Ensure that all settings will allow themselves to be saved. Note that
     2025                 * this is safe because the setting would have checked the capability
     2026                 * when the setting value was written into the changeset. So this is why
     2027                 * an additional capability check is not required here.
     2028                 */
     2029                $original_setting_capabilities = array();
     2030                foreach ( $changeset_setting_ids as $setting_id ) {
     2031                        $setting = $this->get_setting( $setting_id );
     2032                        if ( $setting ) {
     2033                                $original_setting_capabilities[ $setting->id ] = $setting->capability;
     2034                                $setting->capability = 'exist';
     2035                        }
     2036                }
     2037
     2038                foreach ( $changeset_setting_ids as $setting_id ) {
     2039                        $setting = $this->get_setting( $setting_id );
     2040                        if ( $setting ) {
     2041                                $setting->save();
     2042                        }
     2043                }
     2044
     2045                // Update the stashed theme mod settings, removing the active theme's stashed settings, if activated.
     2046                if ( did_action( 'switch_theme' ) ) {
     2047                        $this->update_stashed_theme_mod_settings( $other_theme_mod_settings );
    11232048                }
    11242049
     
    11282053                 * @since 3.6.0
    11292054                 *
    1130                  * @param WP_Customize_Manager $this WP_Customize_Manager instance.
     2055                 * @param WP_Customize_Manager $manager WP_Customize_Manager instance.
    11312056                 */
    11322057                do_action( 'customize_save_after', $this );
    11332058
    1134                 $data = array(
    1135                         'setting_validities' => $exported_setting_validities,
    1136                 );
    1137 
    1138                 /**
    1139                  * Filters response data for a successful customize_save Ajax request.
    1140                  *
    1141                  * This filter does not apply if there was a nonce or authentication failure.
    1142                  *
    1143                  * @since 4.2.0
    1144                  *
    1145                  * @param array                $data Additional information passed back to the 'saved'
    1146                  *                                   event on `wp.customize`.
    1147                  * @param WP_Customize_Manager $this WP_Customize_Manager instance.
    1148                  */
    1149                 $response = apply_filters( 'customize_save_response', $data, $this );
    1150                 wp_send_json_success( $response );
     2059                // Restore original capabilities.
     2060                foreach ( $original_setting_capabilities as $setting_id => $capability ) {
     2061                        $setting = $this->get_setting( $setting_id );
     2062                        if ( $setting ) {
     2063                                $setting->capability = $capability;
     2064                        }
     2065                }
     2066
     2067                // Restore original changeset data.
     2068                $this->_changeset_data    = $previous_changeset_data;
     2069                $this->_changeset_post_id = $previous_changeset_post_id;
     2070                $this->_changeset_uuid    = $previous_changeset_uuid;
     2071
     2072                return true;
     2073        }
     2074
     2075        /**
     2076         * Update stashed theme mod settings.
     2077         *
     2078         * @since 4.7.0
     2079         * @access private
     2080         *
     2081         * @param array $inactive_theme_mod_settings Mapping of stylesheet to arrays of theme mod settings.
     2082         * @return array|false Returns array of updated stashed theme mods or false if the update failed or there were no changes.
     2083         */
     2084        protected function update_stashed_theme_mod_settings( $inactive_theme_mod_settings ) {
     2085                $stashed_theme_mod_settings = get_option( 'customize_stashed_theme_mods' );
     2086                if ( empty( $stashed_theme_mod_settings ) ) {
     2087                        $stashed_theme_mod_settings = array();
     2088                }
     2089
     2090                // Delete any stashed theme mods for the active theme since since they would have been loaded and saved upon activation.
     2091                unset( $stashed_theme_mod_settings[ $this->get_stylesheet() ] );
     2092
     2093                // Merge inactive theme mods with the stashed theme mod settings.
     2094                foreach ( $inactive_theme_mod_settings as $stylesheet => $theme_mod_settings ) {
     2095                        if ( ! isset( $stashed_theme_mod_settings[ $stylesheet ] ) ) {
     2096                                $stashed_theme_mod_settings[ $stylesheet ] = array();
     2097                        }
     2098
     2099                        $stashed_theme_mod_settings[ $stylesheet ] = array_merge(
     2100                                $stashed_theme_mod_settings[ $stylesheet ],
     2101                                $theme_mod_settings
     2102                        );
     2103                }
     2104
     2105                $autoload = false;
     2106                $result = update_option( 'customize_stashed_theme_mods', $stashed_theme_mod_settings, $autoload );
     2107                if ( ! $result ) {
     2108                        return false;
     2109                }
     2110                return $stashed_theme_mod_settings;
    11512111        }
    11522112
     
    16922652
    16932653        /**
     2654         * Determines whether the admin and the frontend are on different domains.
     2655         *
     2656         * @since 4.7.0
     2657         * @access public
     2658         *
     2659         * @return bool Whether cross-domain.
     2660         */
     2661        public function is_cross_domain() {
     2662                $admin_origin = wp_parse_url( admin_url() );
     2663                $home_origin = wp_parse_url( home_url() );
     2664                $cross_domain = ( strtolower( $admin_origin['host'] ) !== strtolower( $home_origin['host'] ) );
     2665                return $cross_domain;
     2666        }
     2667
     2668        /**
     2669         * Get URLs allowed to be previewed.
     2670         *
     2671         * If the front end and the admin are served from the same domain, load the
     2672         * preview over ssl if the Customizer is being loaded over ssl. This avoids
     2673         * insecure content warnings. This is not attempted if the admin and front end
     2674         * are on different domains to avoid the case where the front end doesn't have
     2675         * ssl certs. Domain mapping plugins can allow other urls in these conditions
     2676         * using the customize_allowed_urls filter.
     2677         *
     2678         * @since 4.7.0
     2679         * @access public
     2680         *
     2681         * @returns array Allowed URLs.
     2682         */
     2683        public function get_allowed_urls() {
     2684                $allowed_urls = array( home_url( '/' ) );
     2685
     2686                if ( is_ssl() && ! $this->is_cross_domain() ) {
     2687                        $allowed_urls[] = home_url( '/', 'https' );
     2688                }
     2689
     2690                /**
     2691                 * Filters the list of URLs allowed to be clicked and followed in the Customizer preview.
     2692                 *
     2693                 * @since 3.4.0
     2694                 *
     2695                 * @param array $allowed_urls An array of allowed URLs.
     2696                 */
     2697                $allowed_urls = array_unique( apply_filters( 'customize_allowed_urls', $allowed_urls ) );
     2698
     2699                return $allowed_urls;
     2700        }
     2701
     2702        /**
     2703         * Get messenger channel.
     2704         *
     2705         * @since 4.7.0
     2706         * @access public
     2707         *
     2708         * @return string Messenger channel.
     2709         */
     2710        public function get_messenger_channel() {
     2711                return $this->messenger_channel;
     2712        }
     2713
     2714        /**
    16942715         * Set URL to link the user to when closing the Customizer.
    16952716         *
     
    18002821         */
    18012822        public function customize_pane_settings() {
    1802                 /*
    1803                  * If the front end and the admin are served from the same domain, load the
    1804                  * preview over ssl if the Customizer is being loaded over ssl. This avoids
    1805                  * insecure content warnings. This is not attempted if the admin and front end
    1806                  * are on different domains to avoid the case where the front end doesn't have
    1807                  * ssl certs. Domain mapping plugins can allow other urls in these conditions
    1808                  * using the customize_allowed_urls filter.
    1809                  */
    1810 
    1811                 $allowed_urls = array( home_url( '/' ) );
    1812                 $admin_origin = parse_url( admin_url() );
    1813                 $home_origin  = parse_url( home_url() );
    1814                 $cross_domain = ( strtolower( $admin_origin['host'] ) !== strtolower( $home_origin['host'] ) );
    1815 
    1816                 if ( is_ssl() && ! $cross_domain ) {
    1817                         $allowed_urls[] = home_url( '/', 'https' );
    1818                 }
    1819 
    1820                 /**
    1821                  * Filters the list of URLs allowed to be clicked and followed in the Customizer preview.
    1822                  *
    1823                  * @since 3.4.0
    1824                  *
    1825                  * @param array $allowed_urls An array of allowed URLs.
    1826                  */
    1827                 $allowed_urls = array_unique( apply_filters( 'customize_allowed_urls', $allowed_urls ) );
    18282823
    18292824                $login_url = add_query_arg( array(
     
    18322827                ), wp_login_url() );
    18332828
     2829                // Ensure dirty flags are set for modified settings.
     2830                foreach ( array_keys( $this->unsanitized_post_values() ) as $setting_id ) {
     2831                        $setting = $this->get_setting( $setting_id );
     2832                        if ( $setting ) {
     2833                                $setting->dirty = true;
     2834                        }
     2835                }
     2836
    18342837                // Prepare Customizer settings to pass to JavaScript.
    18352838                $settings = array(
     2839                        'changeset' => array(
     2840                                'uuid' => $this->changeset_uuid(),
     2841                                'status' => $this->changeset_post_id() ? get_post_status( $this->changeset_post_id() ) : '',
     2842                        ),
     2843                        'timeouts' => array(
     2844                                'windowRefresh' => 250,
     2845                                'changesetAutoSave' => AUTOSAVE_INTERVAL * 1000,
     2846                                'keepAliveCheck' => 2500,
     2847                                'reflowPaneContents' => 100,
     2848                                'previewFrameSensitivity' => 2000,
     2849                        ),
    18362850                        'theme'    => array(
    18372851                                'stylesheet' => $this->get_stylesheet(),
     
    18432857                                'activated'     => esc_url_raw( home_url( '/' ) ),
    18442858                                'ajax'          => esc_url_raw( admin_url( 'admin-ajax.php', 'relative' ) ),
    1845                                 'allowed'       => array_map( 'esc_url_raw', $allowed_urls ),
    1846                                 'isCrossDomain' => $cross_domain,
     2859                                'allowed'       => array_map( 'esc_url_raw', $this->get_allowed_urls() ),
     2860                                'isCrossDomain' => $this->is_cross_domain(),
    18472861                                'home'          => esc_url_raw( home_url( '/' ) ),
    18482862                                'login'         => esc_url_raw( $login_url ),
     
    23383352         */
    23393353        public function register_dynamic_settings() {
    2340                 $this->add_dynamic_settings( array_keys( $this->unsanitized_post_values() ) );
     3354                $setting_ids = array_keys( $this->unsanitized_post_values() );
     3355                $this->add_dynamic_settings( $setting_ids );
    23413356        }
    23423357
  • trunk/src/wp-includes/class-wp-customize-nav-menus.php

    r38794 r38810  
    4949                $this->manager         = $manager;
    5050
    51                 // Skip useless hooks when the user can't manage nav menus anyway.
     51                // See https://github.com/xwp/wp-customize-snapshots/blob/962586659688a5b1fd9ae93618b7ce2d4e7a421c/php/class-customize-snapshot-manager.php#L469-L499
     52                add_action( 'customize_register', array( $this, 'customize_register' ), 11 );
     53                add_filter( 'customize_dynamic_setting_args', array( $this, 'filter_dynamic_setting_args' ), 10, 2 );
     54                add_filter( 'customize_dynamic_setting_class', array( $this, 'filter_dynamic_setting_class' ), 10, 3 );
     55
     56                // Skip remaining hooks when the user can't manage nav menus anyway.
    5257                if ( ! current_user_can( 'edit_theme_options' ) ) {
    5358                        return;
     
    5964                add_action( 'wp_ajax_customize-nav-menus-insert-auto-draft', array( $this, 'ajax_insert_auto_draft_post' ) );
    6065                add_action( 'customize_controls_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
    61                 add_action( 'customize_register', array( $this, 'customize_register' ), 11 );
    62                 add_filter( 'customize_dynamic_setting_args', array( $this, 'filter_dynamic_setting_args' ), 10, 2 );
    63                 add_filter( 'customize_dynamic_setting_class', array( $this, 'filter_dynamic_setting_class' ), 10, 3 );
    6466                add_action( 'customize_controls_print_footer_scripts', array( $this, 'print_templates' ) );
    6567                add_action( 'customize_controls_print_footer_scripts', array( $this, 'available_items_template' ) );
     
    487489        public function customize_register() {
    488490
     491                /*
     492                 * Preview settings for nav menus early so that the sections and controls will be added properly.
     493                 * See https://github.com/xwp/wp-customize-snapshots/blob/962586659688a5b1fd9ae93618b7ce2d4e7a421c/php/class-customize-snapshot-manager.php#L506-L543
     494                 */
     495                $nav_menus_setting_ids = array();
     496                foreach ( array_keys( $this->manager->unsanitized_post_values() ) as $setting_id ) {
     497                        if ( preg_match( '/^(nav_menu_locations|nav_menu|nav_menu_item)\[/', $setting_id ) ) {
     498                                $nav_menus_setting_ids[] = $setting_id;
     499                        }
     500                }
     501                foreach ( $nav_menus_setting_ids as $setting_id ) {
     502                        $setting = $this->manager->get_setting( $setting_id );
     503                        if ( $setting ) {
     504                                $setting->preview();
     505                        }
     506                }
     507
    489508                // Require JS-rendered control types.
    490509                $this->manager->register_panel_type( 'WP_Customize_Nav_Menus_Panel' );
  • trunk/src/wp-includes/class-wp-customize-widgets.php

    r38766 r38810  
    9494                $this->manager = $manager;
    9595
    96                 // Skip useless hooks when the user can't manage widgets anyway.
     96                // See https://github.com/xwp/wp-customize-snapshots/blob/962586659688a5b1fd9ae93618b7ce2d4e7a421c/php/class-customize-snapshot-manager.php#L420-L449
     97                add_filter( 'customize_dynamic_setting_args',          array( $this, 'filter_customize_dynamic_setting_args' ), 10, 2 );
     98                add_action( 'widgets_init',                            array( $this, 'register_settings' ), 95 );
     99                add_action( 'customize_register',                      array( $this, 'schedule_customize_register' ), 1 );
     100
     101                // Skip remaining hooks when the user can't manage widgets anyway.
    97102                if ( ! current_user_can( 'edit_theme_options' ) ) {
    98103                        return;
    99104                }
    100105
    101                 add_filter( 'customize_dynamic_setting_args',          array( $this, 'filter_customize_dynamic_setting_args' ), 10, 2 );
    102                 add_action( 'widgets_init',                            array( $this, 'register_settings' ), 95 );
    103106                add_action( 'wp_loaded',                               array( $this, 'override_sidebars_widgets_for_theme_switch' ) );
    104107                add_action( 'customize_controls_init',                 array( $this, 'customize_controls_init' ) );
    105                 add_action( 'customize_register',                      array( $this, 'schedule_customize_register' ), 1 );
    106108                add_action( 'customize_controls_enqueue_scripts',      array( $this, 'enqueue_scripts' ) );
    107109                add_action( 'customize_controls_print_styles',         array( $this, 'print_styles' ) );
     
    277279                $this->old_sidebars_widgets = wp_get_sidebars_widgets();
    278280                add_filter( 'customize_value_old_sidebars_widgets_data', array( $this, 'filter_customize_value_old_sidebars_widgets_data' ) );
     281                $this->manager->set_post_value( 'old_sidebars_widgets_data', $this->old_sidebars_widgets ); // Override any value cached in changeset.
    279282
    280283                // retrieve_widgets() looks at the global $sidebars_widgets
  • trunk/src/wp-includes/customize/class-wp-customize-selective-refresh.php

    r38478 r38810  
    307307                        return;
    308308                }
    309 
    310                 $this->manager->remove_preview_signature();
    311309
    312310                /*
  • trunk/src/wp-includes/customize/class-wp-customize-theme-control.php

    r35389 r38810  
    6464        public function content_template() {
    6565                $current_url = set_url_scheme( 'http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'] );
    66                 $active_url  = esc_url( remove_query_arg( 'theme', $current_url ) );
    67                 $preview_url = esc_url( add_query_arg( 'theme', '__THEME__', $current_url ) ); // Token because esc_url() strips curly braces.
     66                $active_url  = esc_url( remove_query_arg( 'customize_theme', $current_url ) );
     67                $preview_url = esc_url( add_query_arg( 'customize_theme', '__THEME__', $current_url ) ); // Token because esc_url() strips curly braces.
    6868                $preview_url = str_replace( '__THEME__', '{{ data.theme.id }}', $preview_url );
    6969                ?>
  • trunk/src/wp-includes/default-filters.php

    r38778 r38810  
    7676// Slugs
    7777add_filter( 'pre_term_slug', 'sanitize_title' );
     78add_filter( 'wp_insert_post_data', '_wp_customize_changeset_filter_insert_post_data', 10, 2 );
    7879
    7980// Keys
     
    383384add_action( 'wp_head', '_custom_logo_header_styles' );
    384385add_action( 'plugins_loaded', '_wp_customize_include' );
     386add_action( 'transition_post_status', '_wp_customize_publish_changeset', 10, 3 );
    385387add_action( 'admin_enqueue_scripts', '_wp_customize_loader_settings' );
    386388add_action( 'delete_attachment', '_delete_attachment_theme_mod' );
  • trunk/src/wp-includes/functions.php

    r38809 r38810  
    55245524        return false;
    55255525}
     5526
     5527/**
     5528 * Generate a random UUID (version 4).
     5529 *
     5530 * @since 4.7.0
     5531 *
     5532 * @return string UUID.
     5533 */
     5534function wp_generate_uuid4() {
     5535        return sprintf( '%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
     5536                mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ),
     5537                mt_rand( 0, 0xffff ),
     5538                mt_rand( 0, 0x0fff ) | 0x4000,
     5539                mt_rand( 0, 0x3fff ) | 0x8000,
     5540                mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff ), mt_rand( 0, 0xffff )
     5541        );
     5542}
  • trunk/src/wp-includes/functions.wp-scripts.php

    r38519 r38810  
    3535 */
    3636function _wp_scripts_maybe_doing_it_wrong( $function ) {
    37         if ( did_action( 'init' ) ) {
     37        if ( did_action( 'init' ) || did_action( 'admin_enqueue_scripts' ) || did_action( 'wp_enqueue_scripts' ) || did_action( 'login_enqueue_scripts' ) ) {
    3838                return;
    3939        }
  • trunk/src/wp-includes/js/customize-base.js

    r38513 r38810  
    638638                 * Initialize Messenger.
    639639                 *
    640                  * @param  {object} params       Parameters to configure the messenger.
    641                  *         {string} .url          The URL to communicate with.
    642                  *         {window} .targetWindow The window instance to communicate with. Default window.parent.
    643                  *         {string} .channel      If provided, will send the channel with each message and only accept messages a matching channel.
    644                  * @param  {object} options       Extend any instance parameter or method with this object.
     640                 * @param  {object} params - Parameters to configure the messenger.
     641                 *         {string} params.url - The URL to communicate with.
     642                 *         {window} params.targetWindow - The window instance to communicate with. Default window.parent.
     643                 *         {string} params.channel - If provided, will send the channel with each message and only accept messages a matching channel.
     644                 * @param  {object} options - Extend any instance parameter or method with this object.
    645645                 */
    646646                initialize: function( params, options ) {
    647647                        // Target the parent frame by default, but only if a parent frame exists.
    648                         var defaultTarget = window.parent == window ? null : window.parent;
     648                        var defaultTarget = window.parent === window ? null : window.parent;
    649649
    650650                        $.extend( this, options || {} );
     
    653653                        this.add( 'url', params.url || '' );
    654654                        this.add( 'origin', this.url() ).link( this.url ).setter( function( to ) {
    655                                 return to.replace( /([^:]+:\/\/[^\/]+).*/, '$1' );
     655                                var urlParser = document.createElement( 'a' );
     656                                urlParser.href = to;
     657                                return urlParser.protocol + '//' + urlParser.hostname;
    656658                        });
    657659
     
    808810        };
    809811
     812        /**
     813         * Utility function namespace
     814         */
     815        api.utils = {};
     816
     817        /**
     818         * Parse query string.
     819         *
     820         * @since 4.7.0
     821         * @access public
     822         *
     823         * @param {string} queryString Query string.
     824         * @returns {object} Parsed query string.
     825         */
     826        api.utils.parseQueryString = function parseQueryString( queryString ) {
     827                var queryParams = {};
     828                _.each( queryString.split( '&' ), function( pair ) {
     829                        var parts, key, value;
     830                        parts = pair.split( '=', 2 );
     831                        if ( ! parts[0] ) {
     832                                return;
     833                        }
     834                        key = decodeURIComponent( parts[0].replace( /\+/g, ' ' ) );
     835                        key = key.replace( / /g, '_' ); // What PHP does.
     836                        if ( _.isUndefined( parts[1] ) ) {
     837                                value = null;
     838                        } else {
     839                                value = decodeURIComponent( parts[1].replace( /\+/g, ' ' ) );
     840                        }
     841                        queryParams[ key ] = value;
     842                } );
     843                return queryParams;
     844        };
     845
    810846        // Expose the API publicly on window.wp.customize
    811847        exports.customize = api;
  • trunk/src/wp-includes/js/customize-loader.js

    r38520 r38810  
    132132                                targetWindow: this.iframe[0].contentWindow
    133133                        });
     134
     135                        // Expose the changeset UUID on the parent window's URL so that the customized state can survive a refresh.
     136                        if ( history.replaceState ) {
     137                                this.messenger.bind( 'changeset-uuid', function( changesetUuid ) {
     138                                        var urlParser = document.createElement( 'a' );
     139                                        urlParser.href = location.href;
     140                                        urlParser.search = $.param( _.extend(
     141                                                api.utils.parseQueryString( urlParser.search.substr( 1 ) ),
     142                                                { changeset_uuid: changesetUuid }
     143                                        ) );
     144                                        history.replaceState( { customize: urlParser.href }, '', urlParser.href );
     145                                } );
     146                        }
    134147
    135148                        // Wait for the connection from the iframe before sending any postMessage events.
  • trunk/src/wp-includes/js/customize-preview-nav-menus.js

    r36889 r38810  
    107107                         */
    108108                        isRelatedSetting: function( setting, newValue, oldValue ) {
    109                                 var partial = this, navMenuLocationSetting, navMenuId, isNavMenuItemSetting;
     109                                var partial = this, navMenuLocationSetting, navMenuId, isNavMenuItemSetting, _newValue, _oldValue, urlParser;
    110110                                if ( _.isString( setting ) ) {
    111111                                        setting = api( setting );
     
    124124                                isNavMenuItemSetting = /^nav_menu_item\[/.test( setting.id );
    125125                                if ( isNavMenuItemSetting && _.isObject( newValue ) && _.isObject( oldValue ) ) {
    126                                         delete newValue.type_label;
    127                                         delete oldValue.type_label;
    128                                         if ( _.isEqual( oldValue, newValue ) ) {
     126                                        _newValue = _.clone( newValue );
     127                                        _oldValue = _.clone( oldValue );
     128                                        delete _newValue.type_label;
     129                                        delete _oldValue.type_label;
     130
     131                                        // Normalize URL scheme when parent frame is HTTPS to prevent selective refresh upon initial page load.
     132                                        if ( 'https' === api.preview.scheme.get() ) {
     133                                                urlParser = document.createElement( 'a' );
     134                                                urlParser.href = _newValue.url;
     135                                                urlParser.protocol = 'https:';
     136                                                _newValue.url = urlParser.href;
     137                                                urlParser.href = _oldValue.url;
     138                                                urlParser.protocol = 'https:';
     139                                                _oldValue.url = urlParser.href;
     140                                        }
     141
     142                                        if ( _.isEqual( _oldValue, _newValue ) ) {
    129143                                                return false;
    130144                                        }
     
    366380                var selector = '.menu-item';
    367381
     382                // Skip adding highlights if not in the customizer preview iframe.
     383                if ( ! api.settings.channel ) {
     384                        return;
     385                }
     386
    368387                // Focus on the menu item control when shift+clicking the menu item.
    369388                $( document ).on( 'click', selector, function( e ) {
  • trunk/src/wp-includes/js/customize-preview-widgets.js

    r38577 r38810  
    573573                        selector = this.widgetSelectors.join( ',' );
    574574
     575                // Skip adding highlights if not in the customizer preview iframe.
     576                if ( ! api.settings.channel ) {
     577                        return;
     578                }
     579
    575580                $( selector ).attr( 'title', this.l10n.widgetTooltip );
    576581
  • trunk/src/wp-includes/js/customize-preview.js

    r38588 r38810  
    44(function( exports, $ ){
    55        var api = wp.customize,
    6                 debounce;
     6                debounce,
     7                currentHistoryState = {};
     8
     9        /*
     10         * Capture the state that is passed into history.replaceState() and history.pushState()
     11         * and also which is returned in the popstate event so that when the changeset_uuid
     12         * gets updated when transitioning to a new changeset there the current state will
     13         * be supplied in the call to history.replaceState().
     14         */
     15        ( function( history ) {
     16                var injectUrlWithState;
     17
     18                if ( ! history.replaceState ) {
     19                        return;
     20                }
     21
     22                /**
     23                 * Amend the supplied URL with the customized state.
     24                 *
     25                 * @since 4.7.0
     26                 * @access private
     27                 *
     28                 * @param {string} url URL.
     29                 * @returns {string} URL with customized state.
     30                 */
     31                injectUrlWithState = function( url ) {
     32                        var urlParser, queryParams;
     33                        urlParser = document.createElement( 'a' );
     34                        urlParser.href = url;
     35                        queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
     36
     37                        queryParams.customize_changeset_uuid = api.settings.changeset.uuid;
     38                        if ( ! api.settings.theme.active ) {
     39                                queryParams.customize_theme = api.settings.theme.stylesheet;
     40                        }
     41                        if ( api.settings.theme.channel ) {
     42                                queryParams.customize_messenger_channel = api.settings.channel;
     43                        }
     44                        urlParser.search = $.param( queryParams );
     45                        return url;
     46                };
     47
     48                history.replaceState = ( function( nativeReplaceState ) {
     49                        return function historyReplaceState( data, title, url ) {
     50                                currentHistoryState = data;
     51                                return nativeReplaceState.call( history, data, title, injectUrlWithState( url ) );
     52                        };
     53                } )( history.replaceState );
     54
     55                history.pushState = ( function( nativePushState ) {
     56                        return function historyPushState( data, title, url ) {
     57                                currentHistoryState = data;
     58                                return nativePushState.call( history, data, title, injectUrlWithState( url ) );
     59                        };
     60                } )( history.pushState );
     61
     62                window.addEventListener( 'popstate', function( event ) {
     63                        currentHistoryState = event.state;
     64                } );
     65
     66        }( history ) );
    767
    868        /**
     
    3898                 */
    3999                initialize: function( params, options ) {
    40                         var self = this;
    41 
    42                         api.Messenger.prototype.initialize.call( this, params, options );
    43 
    44                         this.body = $( document.body );
    45                         this.body.on( 'click.preview', 'a', function( event ) {
     100                        var preview = this, urlParser = document.createElement( 'a' );
     101
     102                        api.Messenger.prototype.initialize.call( preview, params, options );
     103
     104                        urlParser.href = preview.origin();
     105                        preview.add( 'scheme', urlParser.protocol.replace( /:$/, '' ) );
     106
     107                        preview.body = $( document.body );
     108                        preview.body.on( 'click.preview', 'a', function( event ) {
    46109                                var link, isInternalJumpLink;
    47110                                link = $( this );
     111
     112                                // No-op if the anchor is not a link.
     113                                if ( _.isUndefined( link.attr( 'href' ) ) ) {
     114                                        return;
     115                                }
     116
    48117                                isInternalJumpLink = ( '#' === link.attr( 'href' ).substr( 0, 1 ) );
     118
     119                                // Allow internal jump links to behave normally without preventing default.
     120                                if ( isInternalJumpLink ) {
     121                                        return;
     122                                }
     123
     124                                // If the link is not previewable, prevent the browser from navigating to it.
     125                                if ( ! api.isLinkPreviewable( link[0] ) ) {
     126                                        wp.a11y.speak( api.settings.l10n.linkUnpreviewable );
     127                                        event.preventDefault();
     128                                        return;
     129                                }
     130
     131                                // If not in an iframe, then allow the link click to proceed normally since the state query params are added.
     132                                if ( ! api.settings.channel ) {
     133                                        return;
     134                                }
     135
     136                                // Prevent initiating navigating from click and instead rely on sending url message to pane.
    49137                                event.preventDefault();
    50 
    51                                 if ( isInternalJumpLink && '#' !== link.attr( 'href' ) ) {
    52                                         $( link.attr( 'href' ) ).each( function() {
    53                                                 this.scrollIntoView();
    54                                         } );
    55                                 }
    56138
    57139                                /*
     
    60142                                 * control instead of also navigating to the URL linked to.
    61143                                 */
    62                                 if ( event.shiftKey || isInternalJumpLink ) {
     144                                if ( event.shiftKey ) {
    63145                                        return;
    64146                                }
    65                                 self.send( 'scroll', 0 );
    66                                 self.send( 'url', link.prop( 'href' ) );
    67                         });
    68 
    69                         // You cannot submit forms.
    70                         this.body.on( 'submit.preview', 'form', function( event ) {
    71                                 var urlParser;
     147
     148                                // Note: It's not relevant to send scroll because sending url message will have the same effect.
     149                                preview.send( 'url', link.prop( 'href' ) );
     150                        } );
     151
     152                        preview.body.on( 'submit.preview', 'form', function( event ) {
     153                                var urlParser = document.createElement( 'a' );
     154                                urlParser.href = this.action;
     155
     156                                // If the link is not previewable, prevent the browser from navigating to it.
     157                                if ( 'GET' !== this.method.toUpperCase() || ! api.isLinkPreviewable( urlParser ) ) {
     158                                        wp.a11y.speak( api.settings.l10n.formUnpreviewable );
     159                                        event.preventDefault();
     160                                        return;
     161                                }
     162
     163                                // If not in an iframe, then allow the form submission to proceed normally with the state inputs injected.
     164                                if ( ! api.settings.channel ) {
     165                                        return;
     166                                }
    72167
    73168                                /*
     
    82177                                 * external site in the preview.
    83178                                 */
    84                                 if ( ! event.isDefaultPrevented() && 'GET' === this.method.toUpperCase() ) {
    85                                         urlParser = document.createElement( 'a' );
    86                                         urlParser.href = this.action;
    87                                         if ( urlParser.search.substr( 1 ).length > 1 ) {
     179                                if ( ! event.isDefaultPrevented() ) {
     180                                        if ( urlParser.search.length > 1 ) {
    88181                                                urlParser.search += '&';
    89182                                        }
     
    92185                                }
    93186
     187                                // Prevent default since navigation should be done via sending url message or via JS submit handler.
    94188                                event.preventDefault();
    95189                        });
    96190
    97                         this.window = $( window );
    98                         this.window.on( 'scroll.preview', debounce( function() {
    99                                 self.send( 'scroll', self.window.scrollTop() );
    100                         }, 200 ));
    101 
    102                         this.bind( 'scroll', function( distance ) {
    103                                 self.window.scrollTop( distance );
    104                         });
     191                        preview.window = $( window );
     192
     193                        if ( api.settings.channel ) {
     194                                preview.window.on( 'scroll.preview', debounce( function() {
     195                                        preview.send( 'scroll', preview.window.scrollTop() );
     196                                }, 200 ) );
     197
     198                                preview.bind( 'scroll', function( distance ) {
     199                                        preview.window.scrollTop( distance );
     200                                });
     201                        }
    105202                }
    106203        });
     204
     205        /**
     206         * Inject the changeset UUID into links in the document.
     207         *
     208         * @since 4.7.0
     209         * @access protected
     210         *
     211         * @access private
     212         * @returns {void}
     213         */
     214        api.addLinkPreviewing = function addLinkPreviewing() {
     215                var linkSelectors = 'a[href], area';
     216
     217                // Inject links into initial document.
     218                $( document.body ).find( linkSelectors ).each( function() {
     219                        api.prepareLinkPreview( this );
     220                } );
     221
     222                // Inject links for new elements added to the page.
     223                if ( 'undefined' !== typeof MutationObserver ) {
     224                        api.mutationObserver = new MutationObserver( function( mutations ) {
     225                                _.each( mutations, function( mutation ) {
     226                                        $( mutation.target ).find( linkSelectors ).each( function() {
     227                                                api.prepareLinkPreview( this );
     228                                        } );
     229                                } );
     230                        } );
     231                        api.mutationObserver.observe( document.documentElement, {
     232                                childList: true,
     233                                subtree: true
     234                        } );
     235                } else {
     236
     237                        // If mutation observers aren't available, fallback to just-in-time injection.
     238                        $( document.documentElement ).on( 'click focus mouseover', linkSelectors, function() {
     239                                api.prepareLinkPreview( this );
     240                        } );
     241                }
     242        };
     243
     244        /**
     245         * Should the supplied link is previewable.
     246         *
     247         * @since 4.7.0
     248         * @access public
     249         *
     250         * @param {HTMLAnchorElement|HTMLAreaElement} element Link element.
     251         * @param {string} element.search Query string.
     252         * @param {string} element.pathname Path.
     253         * @param {string} element.hostname Hostname.
     254         * @param {object} [options]
     255         * @param {object} [options.allowAdminAjax=false] Allow admin-ajax.php requests.
     256         * @returns {boolean} Is appropriate for changeset link.
     257         */
     258        api.isLinkPreviewable = function isLinkPreviewable( element, options ) {
     259                var hasMatchingHost, urlParser, args;
     260
     261                args = _.extend( {}, { allowAdminAjax: false }, options || {} );
     262
     263                if ( 'javascript:' === element.protocol ) { // jshint ignore:line
     264                        return true;
     265                }
     266
     267                // Only web URLs can be previewed.
     268                if ( 'https:' !== element.protocol && 'http:' !== element.protocol ) {
     269                        return false;
     270                }
     271
     272                urlParser = document.createElement( 'a' );
     273                hasMatchingHost = ! _.isUndefined( _.find( api.settings.url.allowed, function( allowedUrl ) {
     274                        urlParser.href = allowedUrl;
     275                        if ( urlParser.hostname === element.hostname && urlParser.protocol === element.protocol ) {
     276                                return true;
     277                        }
     278                        return false;
     279                } ) );
     280                if ( ! hasMatchingHost ) {
     281                        return false;
     282                }
     283
     284                // Skip wp login and signup pages.
     285                if ( /\/wp-(login|signup)\.php$/.test( element.pathname ) ) {
     286                        return false;
     287                }
     288
     289                // Allow links to admin ajax as faux frontend URLs.
     290                if ( /\/wp-admin\/admin-ajax\.php$/.test( element.pathname ) ) {
     291                        return args.allowAdminAjax;
     292                }
     293
     294                // Disallow links to admin, includes, and content.
     295                if ( /\/wp-(admin|includes|content)(\/|$)/.test( element.pathname ) ) {
     296                        return false;
     297                }
     298
     299                return true;
     300        };
     301
     302        /**
     303         * Inject the customize_changeset_uuid query param into links on the frontend.
     304         *
     305         * @since 4.7.0
     306         * @access protected
     307         *
     308         * @param {HTMLAnchorElement|HTMLAreaElement} element Link element.
     309         * @param {object} element.search Query string.
     310         * @returns {void}
     311         */
     312        api.prepareLinkPreview = function prepareLinkPreview( element ) {
     313                var queryParams;
     314
     315                // Skip links in admin bar.
     316                if ( $( element ).closest( '#wpadminbar' ).length ) {
     317                        return;
     318                }
     319
     320                // Ignore links with href="#" or href="#id".
     321                if ( '#' === $( element ).attr( 'href' ).substr( 0, 1 ) ) {
     322                        return;
     323                }
     324
     325                // Make sure links in preview use HTTPS if parent frame uses HTTPS.
     326                if ( 'https' === api.preview.scheme.get() && 'http:' === element.protocol && -1 !== api.settings.url.allowedHosts.indexOf( element.hostname ) ) {
     327                        element.protocol = 'https:';
     328                }
     329
     330                if ( ! api.isLinkPreviewable( element ) ) {
     331                        $( element ).addClass( 'customize-unpreviewable' );
     332                        return;
     333                }
     334                $( element ).removeClass( 'customize-unpreviewable' );
     335
     336                queryParams = api.utils.parseQueryString( element.search.substring( 1 ) );
     337                queryParams.customize_changeset_uuid = api.settings.changeset.uuid;
     338                if ( ! api.settings.theme.active ) {
     339                        queryParams.customize_theme = api.settings.theme.stylesheet;
     340                }
     341                if ( api.settings.channel ) {
     342                        queryParams.customize_messenger_channel = api.settings.channel;
     343                }
     344                element.search = $.param( queryParams );
     345
     346                // Prevent links from breaking out of preview iframe.
     347                if ( api.settings.channel ) {
     348                        element.target = '_self';
     349                }
     350        };
     351
     352        /**
     353         * Inject the changeset UUID into Ajax requests.
     354         *
     355         * @since 4.7.0
     356         * @access protected
     357         *
     358         * @return {void}
     359         */
     360        api.addRequestPreviewing = function addRequestPreviewing() {
     361
     362                /**
     363                 * Rewrite Ajax requests to inject customizer state.
     364                 *
     365                 * @param {object} options Options.
     366                 * @param {string} options.type Type.
     367                 * @param {string} options.url URL.
     368                 * @param {object} originalOptions Original options.
     369                 * @param {XMLHttpRequest} xhr XHR.
     370                 * @returns {void}
     371                 */
     372                var prefilterAjax = function( options, originalOptions, xhr ) {
     373                        var urlParser, queryParams, requestMethod, dirtyValues = {};
     374                        urlParser = document.createElement( 'a' );
     375                        urlParser.href = options.url;
     376
     377                        // Abort if the request is not for this site.
     378                        if ( ! api.isLinkPreviewable( urlParser, { allowAdminAjax: true } ) ) {
     379                                return;
     380                        }
     381                        queryParams = api.utils.parseQueryString( urlParser.search.substring( 1 ) );
     382
     383                        // Note that _dirty flag will be cleared with changeset updates.
     384                        api.each( function( setting ) {
     385                                if ( setting._dirty ) {
     386                                        dirtyValues[ setting.id ] = setting.get();
     387                                }
     388                        } );
     389
     390                        if ( ! _.isEmpty( dirtyValues ) ) {
     391                                requestMethod = options.type.toUpperCase();
     392
     393                                // Override underlying request method to ensure unsaved changes to changeset can be included (force Backbone.emulateHTTP).
     394                                if ( 'POST' !== requestMethod ) {
     395                                        xhr.setRequestHeader( 'X-HTTP-Method-Override', requestMethod );
     396                                        queryParams._method = requestMethod;
     397                                        options.type = 'POST';
     398                                }
     399
     400                                // Amend the post data with the customized values.
     401                                if ( options.data ) {
     402                                        options.data += '&';
     403                                } else {
     404                                        options.data = '';
     405                                }
     406                                options.data += $.param( {
     407                                        customized: JSON.stringify( dirtyValues )
     408                                } );
     409                        }
     410
     411                        // Include customized state query params in URL.
     412                        queryParams.customize_changeset_uuid = api.settings.changeset.uuid;
     413                        if ( ! api.settings.theme.active ) {
     414                                queryParams.customize_theme = api.settings.theme.stylesheet;
     415                        }
     416                        urlParser.search = $.param( queryParams );
     417                        options.url = urlParser.href;
     418                };
     419
     420                $.ajaxPrefilter( prefilterAjax );
     421        };
     422
     423        /**
     424         * Inject changeset UUID into forms, allowing preview to persist through submissions.
     425         *
     426         * @since 4.7.0
     427         * @access protected
     428         *
     429         * @returns {void}
     430         */
     431        api.addFormPreviewing = function addFormPreviewing() {
     432
     433                // Inject inputs for forms in initial document.
     434                $( document.body ).find( 'form' ).each( function() {
     435                        api.prepareFormPreview( this );
     436                } );
     437
     438                // Inject inputs for new forms added to the page.
     439                if ( 'undefined' !== typeof MutationObserver ) {
     440                        api.mutationObserver = new MutationObserver( function( mutations ) {
     441                                _.each( mutations, function( mutation ) {
     442                                        $( mutation.target ).find( 'form' ).each( function() {
     443                                                api.prepareFormPreview( this );
     444                                        } );
     445                                } );
     446                        } );
     447                        api.mutationObserver.observe( document.documentElement, {
     448                                childList: true,
     449                                subtree: true
     450                        } );
     451                }
     452        };
     453
     454        /**
     455         * Inject changeset into form inputs.
     456         *
     457         * @since 4.7.0
     458         * @access protected
     459         *
     460         * @param {HTMLFormElement} form Form.
     461         * @returns {void}
     462         */
     463        api.prepareFormPreview = function prepareFormPreview( form ) {
     464                var urlParser, stateParams = {};
     465
     466                if ( ! form.action ) {
     467                        form.action = location.href;
     468                }
     469
     470                urlParser = document.createElement( 'a' );
     471                urlParser.href = form.action;
     472
     473                // Make sure forms in preview use HTTPS if parent frame uses HTTPS.
     474                if ( 'https' === api.preview.scheme.get() && 'http:' === urlParser.protocol && -1 !== api.settings.url.allowedHosts.indexOf( urlParser.hostname ) ) {
     475                        urlParser.protocol = 'https:';
     476                        form.action = urlParser.href;
     477                }
     478
     479                if ( 'GET' !== form.method.toUpperCase() || ! api.isLinkPreviewable( urlParser ) ) {
     480                        $( form ).addClass( 'customize-unpreviewable' );
     481                        return;
     482                }
     483                $( form ).removeClass( 'customize-unpreviewable' );
     484
     485                stateParams.customize_changeset_uuid = api.settings.changeset.uuid;
     486                if ( ! api.settings.theme.active ) {
     487                        stateParams.customize_theme = api.settings.theme.stylesheet;
     488                }
     489                if ( api.settings.channel ) {
     490                        stateParams.customize_messenger_channel = api.settings.channel;
     491                }
     492
     493                _.each( stateParams, function( value, name ) {
     494                        var input = $( form ).find( 'input[name="' + name + '"]' );
     495                        if ( input.length ) {
     496                                input.val( value );
     497                        } else {
     498                                $( form ).prepend( $( '<input>', {
     499                                        type: 'hidden',
     500                                        name: name,
     501                                        value: value
     502                                } ) );
     503                        }
     504                } );
     505
     506                // Prevent links from breaking out of preview iframe.
     507                if ( api.settings.channel ) {
     508                        form.target = '_self';
     509                }
     510        };
     511
     512        /**
     513         * Watch current URL and send keep-alive (heartbeat) messages to the parent.
     514         *
     515         * Keep the customizer pane notified that the preview is still alive
     516         * and that the user hasn't navigated to a non-customized URL.
     517         *
     518         * @since 4.7.0
     519         * @access protected
     520         */
     521        api.keepAliveCurrentUrl = ( function() {
     522                var previousPathName = location.pathname,
     523                        previousQueryString = location.search.substr( 1 ),
     524                        previousQueryParams = null,
     525                        stateQueryParams = [ 'customize_theme', 'customize_changeset_uuid', 'customize_messenger_channel' ];
     526
     527                return function keepAliveCurrentUrl() {
     528                        var urlParser, currentQueryParams;
     529
     530                        // Short-circuit with keep-alive if previous URL is identical (as is normal case).
     531                        if ( previousQueryString === location.search.substr( 1 ) && previousPathName === location.pathname ) {
     532                                api.preview.send( 'keep-alive' );
     533                                return;
     534                        }
     535
     536                        urlParser = document.createElement( 'a' );
     537                        if ( null === previousQueryParams ) {
     538                                urlParser.search = previousQueryString;
     539                                previousQueryParams = api.utils.parseQueryString( previousQueryString );
     540                                _.each( stateQueryParams, function( name ) {
     541                                        delete previousQueryParams[ name ];
     542                                } );
     543                        }
     544
     545                        // Determine if current URL minus customized state params and URL hash.
     546                        urlParser.href = location.href;
     547                        currentQueryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) );
     548                        _.each( stateQueryParams, function( name ) {
     549                                delete currentQueryParams[ name ];
     550                        } );
     551
     552                        if ( previousPathName !== location.pathname || ! _.isEqual( previousQueryParams, currentQueryParams ) ) {
     553                                urlParser.search = $.param( currentQueryParams );
     554                                urlParser.hash = '';
     555                                api.settings.url.self = urlParser.href;
     556                                api.preview.send( 'ready', {
     557                                        currentUrl: api.settings.url.self,
     558                                        activePanels: api.settings.activePanels,
     559                                        activeSections: api.settings.activeSections,
     560                                        activeControls: api.settings.activeControls,
     561                                        settingValidities: api.settings.settingValidities
     562                                } );
     563                        } else {
     564                                api.preview.send( 'keep-alive' );
     565                        }
     566                        previousQueryParams = currentQueryParams;
     567                        previousQueryString = location.search.substr( 1 );
     568                        previousPathName = location.pathname;
     569                };
     570        } )();
    107571
    108572        $( function() {
     
    118582                        channel: api.settings.channel
    119583                });
     584
     585                api.addLinkPreviewing();
     586                api.addRequestPreviewing();
     587                api.addFormPreviewing();
    120588
    121589                /**
     
    172640
    173641                        api.preview.send( 'documentTitle', document.title );
     642
     643                        // Send scroll in case of loading via non-refresh.
     644                        api.preview.send( 'scroll', $( window ).scrollTop() );
    174645                });
    175646
    176647                api.preview.bind( 'saved', function( response ) {
     648
     649                        if ( response.next_changeset_uuid ) {
     650                                api.settings.changeset.uuid = response.next_changeset_uuid;
     651
     652                                // Update UUIDs in links and forms.
     653                                $( document.body ).find( 'a[href], area' ).each( function() {
     654                                        api.prepareLinkPreview( this );
     655                                } );
     656                                $( document.body ).find( 'form' ).each( function() {
     657                                        api.prepareFormPreview( this );
     658                                } );
     659
     660                                /*
     661                                 * Replace the UUID in the URL. Note that the wrapped history.replaceState()
     662                                 * will handle injecting the current api.settings.changeset.uuid into the URL,
     663                                 * so this is merely to trigger that logic.
     664                                 */
     665                                if ( history.replaceState ) {
     666                                        history.replaceState( currentHistoryState, '', location.href );
     667                                }
     668                        }
     669
    177670                        api.trigger( 'saved', response );
    178671                } );
    179672
    180                 api.bind( 'saved', function() {
    181                         api.each( function( setting ) {
    182                                 setting._dirty = false;
     673                /*
     674                 * Clear dirty flag for settings when saved to changeset so that they
     675                 * won't be needlessly included in selective refresh or ajax requests.
     676                 */
     677                api.preview.bind( 'changeset-saved', function( data ) {
     678                        _.each( data.saved_changeset_values, function( value, settingId ) {
     679                                var setting = api( settingId );
     680                                if ( setting && _.isEqual( setting.get(), value ) ) {
     681                                        setting._dirty = false;
     682                                }
    183683                        } );
    184684                } );
     
    193693                 */
    194694                api.preview.send( 'ready', {
     695                        currentUrl: api.settings.url.self,
    195696                        activePanels: api.settings.activePanels,
    196697                        activeSections: api.settings.activeSections,
     
    198699                        settingValidities: api.settings.settingValidities
    199700                } );
     701
     702                // Send ready when URL changes via JS.
     703                setInterval( api.keepAliveCurrentUrl, api.settings.timeouts.keepAliveSend );
    200704
    201705                // Display a loading indicator when preview is reloading, and remove on failure.
  • trunk/src/wp-includes/js/customize-selective-refresh.js

    r38649 r38810  
    1212                        l10n: {
    1313                                shiftClickToEdit: ''
    14                         },
    15                         refreshBuffer: 250
     14                        }
    1615                },
    1716                currentRequest: null
     
    486485                        wp_customize: 'on',
    487486                        nonce: api.settings.nonce.preview,
    488                         theme: api.settings.theme.stylesheet,
    489                         customized: JSON.stringify( dirtyCustomized )
     487                        customize_theme: api.settings.theme.stylesheet,
     488                        customized: JSON.stringify( dirtyCustomized ),
     489                        customize_changeset_uuid: api.settings.changeset.uuid
    490490                };
    491491        };
     
    669669                                } );
    670670                        },
    671                         self.data.refreshBuffer
     671                        api.settings.timeouts.selectiveRefresh
    672672                );
    673673
  • trunk/src/wp-includes/post.php

    r38798 r38810  
    110110                'delete_with_user' => false,
    111111                'query_var' => false,
     112        ) );
     113
     114        register_post_type( 'customize_changeset', array(
     115                'labels' => array(
     116                        'name'               => _x( 'Changesets', 'post type general name' ),
     117                        'singular_name'      => _x( 'Changeset', 'post type singular name' ),
     118                        'menu_name'          => _x( 'Changesets', 'admin menu' ),
     119                        'name_admin_bar'     => _x( 'Changeset', 'add new on admin bar' ),
     120                        'add_new'            => _x( 'Add New', 'Customize Changeset' ),
     121                        'add_new_item'       => __( 'Add New Changeset' ),
     122                        'new_item'           => __( 'New Changeset' ),
     123                        'edit_item'          => __( 'Edit Changeset' ),
     124                        'view_item'          => __( 'View Changeset' ),
     125                        'all_items'          => __( 'All Changesets' ),
     126                        'search_items'       => __( 'Search Changesets' ),
     127                        'not_found'          => __( 'No changesets found.' ),
     128                        'not_found_in_trash' => __( 'No changesets found in Trash.' ),
     129                ),
     130                'public' => false,
     131                '_builtin' => true, /* internal use only. don't use this when registering your own post type. */
     132                'map_meta_cap' => true,
     133                'hierarchical' => false,
     134                'rewrite' => false,
     135                'query_var' => false,
     136                'can_export' => false,
     137                'delete_with_user' => false,
     138                'supports' => array( 'title', 'author' ),
     139                'capability_type' => 'customize_changeset',
     140                'capabilities' => array(
     141                        'create_posts' => 'customize',
     142                        'delete_others_posts' => 'customize',
     143                        'delete_post' => 'customize',
     144                        'delete_posts' => 'customize',
     145                        'delete_private_posts' => 'customize',
     146                        'delete_published_posts' => 'customize',
     147                        'edit_others_posts' => 'customize',
     148                        'edit_post' => 'customize',
     149                        'edit_posts' => 'customize',
     150                        'edit_private_posts' => 'customize',
     151                        'edit_published_posts' => 'do_not_allow',
     152                        'publish_posts' => 'customize',
     153                        'read' => 'read',
     154                        'read_post' => 'customize',
     155                        'read_private_posts' => 'customize',
     156                ),
    112157        ) );
    113158
  • trunk/src/wp-includes/script-loader.php

    r38797 r38810  
    451451        $scripts->add( 'customize-base',     "/wp-includes/js/customize-base$suffix.js",     array( 'jquery', 'json2', 'underscore' ), false, 1 );
    452452        $scripts->add( 'customize-loader',   "/wp-includes/js/customize-loader$suffix.js",   array( 'customize-base' ), false, 1 );
    453         $scripts->add( 'customize-preview',  "/wp-includes/js/customize-preview$suffix.js",  array( 'customize-base' ), false, 1 );
     453        $scripts->add( 'customize-preview',  "/wp-includes/js/customize-preview$suffix.js",  array( 'wp-a11y', 'customize-base' ), false, 1 );
    454454        $scripts->add( 'customize-models',   "/wp-includes/js/customize-models.js", array( 'underscore', 'backbone' ), false, 1 );
    455455        $scripts->add( 'customize-views',    "/wp-includes/js/customize-views.js",  array( 'jquery', 'underscore', 'imgareaselect', 'customize-models', 'media-editor', 'media-views' ), false, 1 );
  • trunk/src/wp-includes/theme.php

    r38649 r38810  
    20672067 *
    20682068 * Loads the Customizer at plugins_loaded when accessing the customize.php admin
    2069  * page or when any request includes a wp_customize=on param, either as a GET
    2070  * query var or as POST data. This param is a signal for whether to bootstrap
    2071  * the Customizer when WordPress is loading, especially in the Customizer preview
     2069 * page or when any request includes a wp_customize=on param or a customize_changeset
     2070 * param (a UUID). This param is a signal for whether to bootstrap the Customizer when
     2071 * WordPress is loading, especially in the Customizer preview
    20722072 * or when making Customizer Ajax requests for widgets or menus.
    20732073 *
     
    20772077 */
    20782078function _wp_customize_include() {
    2079         if ( ! ( ( isset( $_REQUEST['wp_customize'] ) && 'on' == $_REQUEST['wp_customize'] )
    2080                 || ( is_admin() && 'customize.php' == basename( $_SERVER['PHP_SELF'] ) )
    2081         ) ) {
     2079
     2080        $is_customize_admin_page = ( is_admin() && 'customize.php' == basename( $_SERVER['PHP_SELF'] ) );
     2081        $should_include = (
     2082                $is_customize_admin_page
     2083                ||
     2084                ( isset( $_REQUEST['wp_customize'] ) && 'on' == $_REQUEST['wp_customize'] )
     2085                ||
     2086                ( ! empty( $_GET['customize_changeset_uuid'] ) || ! empty( $_POST['customize_changeset_uuid'] ) )
     2087        );
     2088
     2089        if ( ! $should_include ) {
    20822090                return;
    20832091        }
    20842092
    2085         require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';
    2086         $GLOBALS['wp_customize'] = new WP_Customize_Manager();
     2093        /*
     2094         * Note that wp_unslash() is not being used on the input vars because it is
     2095         * called before wp_magic_quotes() gets called. Besides this fact, none of
     2096         * the values should contain any characters needing slashes anyway.
     2097         */
     2098        $keys = array( 'changeset_uuid', 'customize_changeset_uuid', 'customize_theme', 'theme', 'customize_messenger_channel' );
     2099        $input_vars = array_merge(
     2100                wp_array_slice_assoc( $_GET, $keys ),
     2101                wp_array_slice_assoc( $_POST, $keys )
     2102        );
     2103
     2104        $theme = null;
     2105        $changeset_uuid = null;
     2106        $messenger_channel = null;
     2107
     2108        if ( $is_customize_admin_page && isset( $input_vars['changeset_uuid'] ) ) {
     2109                $changeset_uuid = sanitize_key( $input_vars['changeset_uuid'] );
     2110        } elseif ( ! empty( $input_vars['customize_changeset_uuid'] ) ) {
     2111                $changeset_uuid = sanitize_key( $input_vars['customize_changeset_uuid'] );
     2112        }
     2113
     2114        // Note that theme will be sanitized via WP_Theme.
     2115        if ( $is_customize_admin_page && isset( $input_vars['theme'] ) ) {
     2116                $theme = $input_vars['theme'];
     2117        } elseif ( isset( $input_vars['customize_theme'] ) ) {
     2118                $theme = $input_vars['customize_theme'];
     2119        }
     2120        if ( isset( $input_vars['customize_messenger_channel'] ) ) {
     2121                $messenger_channel = sanitize_key( $input_vars['customize_messenger_channel'] );
     2122        }
     2123
     2124        require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';
     2125        $GLOBALS['wp_customize'] = new WP_Customize_Manager( compact( 'changeset_uuid', 'theme', 'messenger_channel' ) );
     2126}
     2127
     2128/**
     2129 * Publish a snapshot's changes.
     2130 *
     2131 * @param string  $new_status     New post status.
     2132 * @param string  $old_status     Old post status.
     2133 * @param WP_Post $changeset_post Changeset post object.
     2134 */
     2135function _wp_customize_publish_changeset( $new_status, $old_status, $changeset_post ) {
     2136        global $wp_customize;
     2137
     2138        $is_publishing_changeset = (
     2139                'customize_changeset' === $changeset_post->post_type
     2140                &&
     2141                'publish' === $new_status
     2142                &&
     2143                'publish' !== $old_status
     2144        );
     2145        if ( ! $is_publishing_changeset ) {
     2146                return;
     2147        }
     2148
     2149        if ( empty( $wp_customize ) ) {
     2150                require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';
     2151                $wp_customize = new WP_Customize_Manager( $changeset_post->post_name );
     2152        }
     2153
     2154        if ( ! did_action( 'customize_register' ) ) {
     2155                /*
     2156                 * When running from CLI or Cron, the customize_register action will need
     2157                 * to be triggered in order for core, themes, and plugins to register their
     2158                 * settings. Normally core will add_action( 'customize_register' ) at
     2159                 * priority 10 to register the core settings, and if any themes/plugins
     2160                 * also add_action( 'customize_register' ) at the same priority, they
     2161                 * will have a $wp_customize with those settings registered since they
     2162                 * call add_action() afterward, normally. However, when manually doing
     2163                 * the customize_register action after the setup_theme, then the order
     2164                 * will be reversed for two actions added at priority 10, resulting in
     2165                 * the core settings no longer being available as expected to themes/plugins.
     2166                 * So the following manually calls the method that registers the core
     2167                 * settings up front before doing the action.
     2168                 */
     2169                remove_action( 'customize_register', array( $wp_customize, 'register_controls' ) );
     2170                $wp_customize->register_controls();
     2171
     2172                /** This filter is documented in /wp-includes/class-wp-customize-manager.php */
     2173                do_action( 'customize_register', $wp_customize );
     2174        }
     2175        $wp_customize->_publish_changeset_values( $changeset_post->ID ) ;
     2176
     2177        /*
     2178         * Trash the changeset post if revisions are not enabled. Unpublished
     2179         * changesets by default get garbage collected due to the auto-draft status.
     2180         * When a changeset post is published, however, it would no longer get cleaned
     2181         * out. Ths is a problem when the changeset posts are never displayed anywhere,
     2182         * since they would just be endlessly piling up. So here we use the revisions
     2183         * feature to indicate whether or not a published changeset should get trashed
     2184         * and thus garbage collected.
     2185         */
     2186        if ( ! wp_revisions_enabled( $changeset_post ) ) {
     2187                wp_trash_post( $changeset_post->ID );
     2188        }
     2189}
     2190
     2191/**
     2192 * Filters changeset post data upon insert to ensure post_name is intact.
     2193 *
     2194 * This is needed to prevent the post_name from being dropped when the post is
     2195 * transitioned into pending status by a contributor.
     2196 *
     2197 * @since 4.7.0
     2198 * @see wp_insert_post()
     2199 *
     2200 * @param array $post_data          An array of slashed post data.
     2201 * @param array $supplied_post_data An array of sanitized, but otherwise unmodified post data.
     2202 * @returns array Filtered data.
     2203 */
     2204function _wp_customize_changeset_filter_insert_post_data( $post_data, $supplied_post_data ) {
     2205        if ( isset( $post_data['post_type'] ) && 'customize_changeset' === $post_data['post_type'] ) {
     2206
     2207                // Prevent post_name from being dropped, such as when contributor saves a changeset post as pending.
     2208                if ( empty( $post_data['post_name'] ) && ! empty( $supplied_post_data['post_name'] ) ) {
     2209                        $post_data['post_name'] = $supplied_post_data['post_name'];
     2210                }
     2211        }
     2212        return $post_data;
    20872213}
    20882214
  • trunk/tests/phpunit/tests/adminbar.php

    r38708 r38810  
    551551                $this->assertNull( $node );
    552552        }
     553
     554        /**
     555         * @ticket 30937
     556         * @covers wp_admin_bar_customize_menu()
     557         */
     558        public function test_customize_link() {
     559                global $wp_customize;
     560                require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';
     561                $uuid = wp_generate_uuid4();
     562                $this->go_to( home_url( "/?customize_changeset_uuid=$uuid" ) );
     563                wp_set_current_user( self::$admin_id );
     564
     565                $this->factory()->post->create( array(
     566                        'post_type' => 'customize_changeset',
     567                        'post_status' => 'auto-draft',
     568                        'post_name' => $uuid,
     569                ) );
     570                $wp_customize = new WP_Customize_Manager( array(
     571                        'changeset_uuid' => $uuid,
     572                ) );
     573                $wp_customize->start_previewing_theme();
     574
     575                set_current_screen( 'front' );
     576                $wp_admin_bar = $this->get_standard_admin_bar();
     577                $node = $wp_admin_bar->get_node( 'customize' );
     578                $this->assertNotEmpty( $node );
     579
     580                $parsed_url = wp_parse_url( $node->href );
     581                $query_params = array();
     582                wp_parse_str( $parsed_url['query'], $query_params );
     583                $this->assertEquals( $uuid, $query_params['changeset_uuid'] );
     584                $this->assertNotContains( 'changeset_uuid', $query_params['url'] );
     585        }
    553586}
  • trunk/tests/phpunit/tests/customize/manager.php

    r38765 r38810  
    2828
    2929        /**
     30         * Admin user ID.
     31         *
     32         * @var int
     33         */
     34        protected static $admin_user_id;
     35
     36        /**
     37         * Subscriber user ID.
     38         *
     39         * @var int
     40         */
     41        protected static $subscriber_user_id;
     42
     43        /**
     44         * Set up before class.
     45         *
     46         * @param WP_UnitTest_Factory $factory Factory.
     47         */
     48        public static function wpSetUpBeforeClass( $factory ) {
     49                self::$subscriber_user_id = $factory->user->create( array( 'role' => 'subscriber' ) );
     50                self::$admin_user_id = $factory->user->create( array( 'role' => 'administrator' ) );
     51        }
     52
     53        /**
    3054         * Set up test.
    3155         */
     
    4367                $this->manager = null;
    4468                unset( $GLOBALS['wp_customize'] );
     69                $_REQUEST = array();
    4570                parent::tearDown();
    4671        }
     
    5479                $GLOBALS['wp_customize'] = new WP_Customize_Manager();
    5580                return $GLOBALS['wp_customize'];
     81        }
     82
     83        /**
     84         * Test WP_Customize_Manager::__construct().
     85         *
     86         * @covers WP_Customize_Manager::__construct()
     87         */
     88        function test_constructor() {
     89                $uuid = wp_generate_uuid4();
     90                $theme = 'twentyfifteen';
     91                $messenger_channel = 'preview-123';
     92                $wp_customize = new WP_Customize_Manager( array(
     93                        'changeset_uuid' => $uuid,
     94                        'theme' => $theme,
     95                        'messenger_channel' => $messenger_channel,
     96                ) );
     97                $this->assertEquals( $uuid, $wp_customize->changeset_uuid() );
     98                $this->assertEquals( $theme, $wp_customize->get_stylesheet() );
     99                $this->assertEquals( $messenger_channel, $wp_customize->get_messenger_channel() );
     100
     101                $theme = 'twentyfourteen';
     102                $messenger_channel = 'preview-456';
     103                $_REQUEST['theme'] = $theme;
     104                $_REQUEST['customize_messenger_channel'] = $messenger_channel;
     105                $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
     106                $this->assertEquals( $theme, $wp_customize->get_stylesheet() );
     107                $this->assertEquals( $messenger_channel, $wp_customize->get_messenger_channel() );
     108
     109                $theme = 'twentyfourteen';
     110                $_REQUEST['customize_theme'] = $theme;
     111                $wp_customize = new WP_Customize_Manager();
     112                $this->assertEquals( $theme, $wp_customize->get_stylesheet() );
     113                $this->assertNotEmpty( $wp_customize->changeset_uuid() );
     114        }
     115
     116        /**
     117         * Test WP_Customize_Manager::setup_theme() for admin screen.
     118         *
     119         * @covers WP_Customize_Manager::setup_theme()
     120         */
     121        function test_setup_theme_in_customize_admin() {
     122                global $pagenow, $wp_customize;
     123                $pagenow = 'customize.php';
     124                set_current_screen( 'customize' );
     125
     126                // Unauthorized.
     127                $exception = null;
     128                $wp_customize = new WP_Customize_Manager();
     129                wp_set_current_user( self::$subscriber_user_id );
     130                try {
     131                        $wp_customize->setup_theme();
     132                } catch ( Exception $e ) {
     133                        $exception = $e;
     134                }
     135                $this->assertInstanceOf( 'WPDieException', $exception );
     136                $this->assertContains( 'you are not allowed to customize this site', $exception->getMessage() );
     137
     138                // Bad changeset.
     139                $exception = null;
     140                wp_set_current_user( self::$admin_user_id );
     141                $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => 'bad' ) );
     142                try {
     143                        $wp_customize->setup_theme();
     144                } catch ( Exception $e ) {
     145                        $exception = $e;
     146                }
     147                $this->assertInstanceOf( 'WPDieException', $exception );
     148                $this->assertContains( 'Invalid changeset UUID', $exception->getMessage() );
     149
     150                $wp_customize = new WP_Customize_Manager();
     151                $wp_customize->setup_theme();
     152        }
     153
     154        /**
     155         * Test WP_Customize_Manager::setup_theme() for frontend.
     156         *
     157         * @covers WP_Customize_Manager::setup_theme()
     158         */
     159        function test_setup_theme_in_frontend() {
     160                global $wp_customize, $pagenow, $show_admin_bar;
     161                $pagenow = 'front';
     162                set_current_screen( 'front' );
     163
     164                wp_set_current_user( 0 );
     165                $exception = null;
     166                $wp_customize = new WP_Customize_Manager();
     167                wp_set_current_user( self::$subscriber_user_id );
     168                try {
     169                        $wp_customize->setup_theme();
     170                } catch ( Exception $e ) {
     171                        $exception = $e;
     172                }
     173                $this->assertInstanceOf( 'WPDieException', $exception );
     174                $this->assertContains( 'Non-existent changeset UUID', $exception->getMessage() );
     175
     176                wp_set_current_user( self::$admin_user_id );
     177                $wp_customize = new WP_Customize_Manager( array( 'messenger_channel' => 'preview-1' ) );
     178                $wp_customize->setup_theme();
     179                $this->assertFalse( $show_admin_bar );
     180
     181                show_admin_bar( true );
     182                wp_set_current_user( self::$admin_user_id );
     183                $wp_customize = new WP_Customize_Manager( array( 'messenger_channel' => null ) );
     184                $wp_customize->setup_theme();
     185                $this->assertTrue( $show_admin_bar );
     186        }
     187
     188        /**
     189         * Test WP_Customize_Manager::changeset_uuid().
     190         *
     191         * @ticket 30937
     192         * @covers WP_Customize_Manager::changeset_uuid()
     193         */
     194        function test_changeset_uuid() {
     195                $uuid = wp_generate_uuid4();
     196                $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
     197                $this->assertEquals( $uuid, $wp_customize->changeset_uuid() );
     198        }
     199
     200        /**
     201         * Test WP_Customize_Manager::wp_loaded().
     202         *
     203         * Ensure that post values are previewed even without being in preview.
     204         *
     205         * @ticket 30937
     206         * @covers WP_Customize_Manager::wp_loaded()
     207         */
     208        function test_wp_loaded() {
     209                wp_set_current_user( self::$admin_user_id );
     210                $wp_customize = new WP_Customize_Manager();
     211                $title = 'Hello World';
     212                $wp_customize->set_post_value( 'blogname', $title );
     213                $this->assertNotEquals( $title, get_option( 'blogname' ) );
     214                $wp_customize->wp_loaded();
     215                $this->assertFalse( $wp_customize->is_preview() );
     216                $this->assertEquals( $title, $wp_customize->get_setting( 'blogname' )->value() );
     217                $this->assertEquals( $title, get_option( 'blogname' ) );
     218        }
     219
     220        /**
     221         * Test WP_Customize_Manager::find_changeset_post_id().
     222         *
     223         * @ticket 30937
     224         * @covers WP_Customize_Manager::find_changeset_post_id()
     225         */
     226        function test_find_changeset_post_id() {
     227                $uuid = wp_generate_uuid4();
     228                $post_id = $this->factory()->post->create( array(
     229                        'post_name' => $uuid,
     230                        'post_type' => 'customize_changeset',
     231                        'post_status' => 'auto-draft',
     232                        'post_content' => '{}',
     233                ) );
     234
     235                $wp_customize = new WP_Customize_Manager();
     236                $this->assertNull( $wp_customize->find_changeset_post_id( wp_generate_uuid4() ) );
     237                $this->assertEquals( $post_id, $wp_customize->find_changeset_post_id( $uuid ) );
     238        }
     239
     240        /**
     241         * Test WP_Customize_Manager::changeset_post_id().
     242         *
     243         * @ticket 30937
     244         * @covers WP_Customize_Manager::changeset_post_id()
     245         */
     246        function test_changeset_post_id() {
     247                $uuid = wp_generate_uuid4();
     248                $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
     249                $this->assertNull( $wp_customize->changeset_post_id() );
     250
     251                $uuid = wp_generate_uuid4();
     252                $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
     253                $post_id = $this->factory()->post->create( array(
     254                        'post_name' => $uuid,
     255                        'post_type' => 'customize_changeset',
     256                        'post_status' => 'auto-draft',
     257                        'post_content' => '{}',
     258                ) );
     259                $this->assertEquals( $post_id, $wp_customize->changeset_post_id() );
     260        }
     261
     262        /**
     263         * Test WP_Customize_Manager::changeset_data().
     264         *
     265         * @ticket 30937
     266         * @covers WP_Customize_Manager::changeset_data()
     267         */
     268        function test_changeset_data() {
     269                $uuid = wp_generate_uuid4();
     270                $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
     271                $this->assertEquals( array(), $wp_customize->changeset_data() );
     272
     273                $uuid = wp_generate_uuid4();
     274                $data = array( 'blogname' => array( 'value' => 'Hello World' ) );
     275                $this->factory()->post->create( array(
     276                        'post_name' => $uuid,
     277                        'post_type' => 'customize_changeset',
     278                        'post_status' => 'auto-draft',
     279                        'post_content' => wp_json_encode( $data ),
     280                ) );
     281                $wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
     282                $this->assertEquals( $data, $wp_customize->changeset_data() );
     283        }
     284
     285        /**
     286         * Test WP_Customize_Manager::customize_preview_init().
     287         *
     288         * @ticket 30937
     289         * @covers WP_Customize_Manager::customize_preview_init()
     290         */
     291        function test_customize_preview_init() {
     292
     293                // Test authorized admin user.
     294                wp_set_current_user( self::$admin_user_id );
     295                $did_action_customize_preview_init = did_action( 'customize_preview_init' );
     296                $wp_customize = new WP_Customize_Manager();
     297                $wp_customize->customize_preview_init();
     298                $this->assertEquals( $did_action_customize_preview_init + 1, did_action( 'customize_preview_init' ) );
     299
     300                $this->assertEquals( 10, has_action( 'wp_head', 'wp_no_robots' ) );
     301                $this->assertEquals( 10, has_filter( 'wp_headers', array( $wp_customize, 'filter_iframe_security_headers' ) ) );
     302                $this->assertEquals( 10, has_filter( 'wp_redirect', array( $wp_customize, 'add_state_query_params' ) ) );
     303                $this->assertTrue( wp_script_is( 'customize-preview', 'enqueued' ) );
     304                $this->assertEquals( 10, has_action( 'wp_head', array( $wp_customize, 'customize_preview_loading_style' ) ) );
     305                $this->assertEquals( 20, has_action( 'wp_footer', array( $wp_customize, 'customize_preview_settings' ) ) );
     306
     307                // Test unauthorized user outside preview (no messenger_channel).
     308                wp_set_current_user( self::$subscriber_user_id );
     309                $wp_customize = new WP_Customize_Manager();
     310                $wp_customize->register_controls();
     311                $this->assertNotEmpty( $wp_customize->controls() );
     312                $wp_customize->customize_preview_init();
     313                $this->assertEmpty( $wp_customize->controls() );
     314
     315                // Test unauthorized user inside preview (with messenger_channel).
     316                wp_set_current_user( self::$subscriber_user_id );
     317                $wp_customize = new WP_Customize_Manager( array( 'messenger_channel' => 'preview-0' ) );
     318                $exception = null;
     319                try {
     320                        $wp_customize->customize_preview_init();
     321                } catch ( WPDieException $e ) {
     322                        $exception = $e;
     323                }
     324                $this->assertNotNull( $exception );
     325                $this->assertContains( 'Unauthorized', $exception->getMessage() );
     326        }
     327
     328        /**
     329         * Test WP_Customize_Manager::filter_iframe_security_headers().
     330         *
     331         * @ticket 30937
     332         * @covers WP_Customize_Manager::filter_iframe_security_headers()
     333         */
     334        function test_filter_iframe_security_headers() {
     335                $customize_url = admin_url( 'customize.php' );
     336                $wp_customize = new WP_Customize_Manager();
     337                $headers = $wp_customize->filter_iframe_security_headers( array() );
     338                $this->assertArrayHasKey( 'X-Frame-Options', $headers );
     339                $this->assertArrayHasKey( 'Content-Security-Policy', $headers );
     340                $this->assertEquals( "ALLOW-FROM $customize_url", $headers['X-Frame-Options'] );
     341        }
     342
     343        /**
     344         * Test WP_Customize_Manager::add_state_query_params().
     345         *
     346         * @ticket 30937
     347         * @covers WP_Customize_Manager::add_state_query_params()
     348         */
     349        function test_add_state_query_params() {
     350                $uuid = wp_generate_uuid4();
     351                $messenger_channel = 'preview-0';
     352                $wp_customize = new WP_Customize_Manager( array(
     353                        'changeset_uuid' => $uuid,
     354                        'messenger_channel' => $messenger_channel,
     355                ) );
     356                $url = $wp_customize->add_state_query_params( home_url( '/' ) );
     357                $parsed_url = wp_parse_url( $url );
     358                parse_str( $parsed_url['query'], $query_params );
     359                $this->assertArrayHasKey( 'customize_messenger_channel', $query_params );
     360                $this->assertArrayHasKey( 'customize_changeset_uuid', $query_params );
     361                $this->assertArrayNotHasKey( 'customize_theme', $query_params );
     362                $this->assertEquals( $uuid, $query_params['customize_changeset_uuid'] );
     363                $this->assertEquals( $messenger_channel, $query_params['customize_messenger_channel'] );
     364
     365                $uuid = wp_generate_uuid4();
     366                $wp_customize = new WP_Customize_Manager( array(
     367                        'changeset_uuid' => $uuid,
     368                        'messenger_channel' => null,
     369                        'theme' => 'twentyfifteen',
     370                ) );
     371                $url = $wp_customize->add_state_query_params( home_url( '/' ) );
     372                $parsed_url = wp_parse_url( $url );
     373                parse_str( $parsed_url['query'], $query_params );
     374                $this->assertArrayNotHasKey( 'customize_messenger_channel', $query_params );
     375                $this->assertArrayHasKey( 'customize_changeset_uuid', $query_params );
     376                $this->assertArrayHasKey( 'customize_theme', $query_params );
     377                $this->assertEquals( $uuid, $query_params['customize_changeset_uuid'] );
     378                $this->assertEquals( 'twentyfifteen', $query_params['customize_theme'] );
     379
     380                $uuid = wp_generate_uuid4();
     381                $wp_customize = new WP_Customize_Manager( array(
     382                        'changeset_uuid' => $uuid,
     383                        'messenger_channel' => null,
     384                        'theme' => 'twentyfifteen',
     385                ) );
     386                $url = $wp_customize->add_state_query_params( 'http://not-allowed.example.com/?q=1' );
     387                $parsed_url = wp_parse_url( $url );
     388                parse_str( $parsed_url['query'], $query_params );
     389                $this->assertArrayNotHasKey( 'customize_messenger_channel', $query_params );
     390                $this->assertArrayNotHasKey( 'customize_changeset_uuid', $query_params );
     391                $this->assertArrayNotHasKey( 'customize_theme', $query_params );
     392        }
     393
     394        /**
     395         * Test WP_Customize_Manager::save_changeset_post().
     396         *
     397         * @ticket 30937
     398         * @covers WP_Customize_Manager::save_changeset_post()
     399         */
     400        function test_save_changeset_post_without_theme_activation() {
     401                wp_set_current_user( self::$admin_user_id );
     402
     403                $did_action = array(
     404                        'customize_save_validation_before' => did_action( 'customize_save_validation_before' ),
     405                        'customize_save' => did_action( 'customize_save' ),
     406                        'customize_save_after' => did_action( 'customize_save_after' ),
     407                );
     408                $uuid = wp_generate_uuid4();
     409
     410                $manager = new WP_Customize_Manager( array(
     411                        'changeset_uuid' => $uuid,
     412                ) );
     413                $manager->register_controls();
     414                $manager->set_post_value( 'blogname', 'Changeset Title' );
     415                $manager->set_post_value( 'blogdescription', 'Changeset Tagline' );
     416
     417                $r = $manager->save_changeset_post( array(
     418                        'status' => 'auto-draft',
     419                        'title' => 'Auto Draft',
     420                        'date_gmt' => '2010-01-01 00:00:00',
     421                        'data' => array(
     422                                'blogname' => array(
     423                                        'value' => 'Overridden Changeset Title',
     424                                ),
     425                                'blogdescription' => array(
     426                                        'custom' => 'something',
     427                                ),
     428                        ),
     429                ) );
     430                $this->assertInternalType( 'array', $r );
     431
     432                $this->assertEquals( $did_action['customize_save_validation_before'] + 1, did_action( 'customize_save_validation_before' ) );
     433
     434                $post_id = $manager->find_changeset_post_id( $uuid );
     435                $this->assertNotNull( $post_id );
     436                $saved_data = json_decode( get_post( $post_id )->post_content, true );
     437                $this->assertEquals( $manager->unsanitized_post_values(), wp_list_pluck( $saved_data, 'value' ) );
     438                $this->assertEquals( 'Overridden Changeset Title', $saved_data['blogname']['value'] );
     439                $this->assertEquals( 'something', $saved_data['blogdescription']['custom'] );
     440                $this->assertEquals( 'Auto Draft', get_post( $post_id )->post_title );
     441                $this->assertEquals( 'auto-draft', get_post( $post_id )->post_status );
     442                $this->assertEquals( '2010-01-01 00:00:00', get_post( $post_id )->post_date_gmt );
     443                $this->assertNotEquals( 'Changeset Title', get_option( 'blogname' ) );
     444                $this->assertArrayHasKey( 'setting_validities', $r );
     445
     446                // Test saving with invalid settings, ensuring transaction blocked.
     447                $previous_saved_data = $saved_data;
     448                $manager->add_setting( 'foo_unauthorized', array(
     449                        'capability' => 'do_not_allow',
     450                ) );
     451                $manager->add_setting( 'baz_illegal', array(
     452                        'validate_callback' => array( $this, 'return_illegal_error' ),
     453                ) );
     454                $r = $manager->save_changeset_post( array(
     455                        'status' => 'auto-draft',
     456                        'data' => array(
     457                                'blogname' => array(
     458                                        'value' => 'OK',
     459                                ),
     460                                'foo_unauthorized' => array(
     461                                        'value' => 'No',
     462                                ),
     463                                'bar_unknown' => array(
     464                                        'value' => 'No',
     465                                ),
     466                                'baz_illegal' => array(
     467                                        'value' => 'No',
     468                                ),
     469                        ),
     470                ) );
     471                $this->assertInstanceOf( 'WP_Error', $r );
     472                $this->assertEquals( 'transaction_fail', $r->get_error_code() );
     473                $this->assertInternalType( 'array', $r->get_error_data() );
     474                $this->assertArrayHasKey( 'setting_validities', $r->get_error_data() );
     475                $error_data = $r->get_error_data();
     476                $this->assertArrayHasKey( 'blogname', $error_data['setting_validities'] );
     477                $this->assertTrue( $error_data['setting_validities']['blogname'] );
     478                $this->assertArrayHasKey( 'foo_unauthorized', $error_data['setting_validities'] );
     479                $this->assertInstanceOf( 'WP_Error', $error_data['setting_validities']['foo_unauthorized'] );
     480                $this->assertEquals( 'unauthorized', $error_data['setting_validities']['foo_unauthorized']->get_error_code() );
     481                $this->assertArrayHasKey( 'bar_unknown', $error_data['setting_validities'] );
     482                $this->assertInstanceOf( 'WP_Error', $error_data['setting_validities']['bar_unknown'] );
     483                $this->assertEquals( 'unrecognized', $error_data['setting_validities']['bar_unknown']->get_error_code() );
     484                $this->assertArrayHasKey( 'baz_illegal', $error_data['setting_validities'] );
     485                $this->assertInstanceOf( 'WP_Error', $error_data['setting_validities']['baz_illegal'] );
     486                $this->assertEquals( 'illegal', $error_data['setting_validities']['baz_illegal']->get_error_code() );
     487
     488                // Since transactional, ensure no changes have been made.
     489                $this->assertEquals( $previous_saved_data, json_decode( get_post( $post_id )->post_content, true ) );
     490
     491                // Attempt a non-transactional/incremental update.
     492                $manager = new WP_Customize_Manager( array(
     493                        'changeset_uuid' => $uuid,
     494                ) );
     495                $manager->register_controls(); // That is, register settings.
     496                $r = $manager->save_changeset_post( array(
     497                        'status' => null,
     498                        'data' => array(
     499                                'blogname' => array(
     500                                        'value' => 'Non-Transactional \o/ <script>unsanitized</script>',
     501                                ),
     502                                'bar_unknown' => array(
     503                                        'value' => 'No',
     504                                ),
     505                        ),
     506                ) );
     507                $this->assertInternalType( 'array', $r );
     508                $this->assertArrayHasKey( 'setting_validities', $r );
     509                $this->assertTrue( $r['setting_validities']['blogname'] );
     510                $this->assertInstanceOf( 'WP_Error', $r['setting_validities']['bar_unknown'] );
     511                $saved_data = json_decode( get_post( $post_id )->post_content, true );
     512                $this->assertNotEquals( $previous_saved_data, $saved_data );
     513                $this->assertEquals( 'Non-Transactional \o/ <script>unsanitized</script>', $saved_data['blogname']['value'] );
     514
     515                // Ensure the filter applies.
     516                $customize_changeset_save_data_call_count = $this->customize_changeset_save_data_call_count;
     517                add_filter( 'customize_changeset_save_data', array( $this, 'filter_customize_changeset_save_data' ), 10, 2 );
     518                $manager->save_changeset_post( array(
     519                        'status' => null,
     520                        'data' => array(
     521                                'blogname' => array(
     522                                        'value' => 'Filtered',
     523                                ),
     524                        ),
     525                ) );
     526                $this->assertEquals( $customize_changeset_save_data_call_count + 1, $this->customize_changeset_save_data_call_count );
     527
     528                // Publish the changeset.
     529                $manager = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
     530                $manager->register_controls();
     531                $GLOBALS['wp_customize'] = $manager;
     532                $r = $manager->save_changeset_post( array(
     533                        'status' => 'publish',
     534                        'data' => array(
     535                                'blogname' => array(
     536                                        'value' => 'Do it live \o/',
     537                                ),
     538                        ),
     539                ) );
     540                $this->assertInternalType( 'array', $r );
     541                $this->assertEquals( 'Do it live \o/', get_option( 'blogname' ) );
     542                $this->assertEquals( 'trash', get_post_status( $post_id ) ); // Auto-trashed.
     543
     544                // Test revisions.
     545                add_post_type_support( 'customize_changeset', 'revisions' );
     546                $uuid = wp_generate_uuid4();
     547                $manager = new WP_Customize_Manager( array( 'changeset_uuid' => $uuid ) );
     548                $manager->register_controls();
     549                $GLOBALS['wp_customize'] = $manager;
     550
     551                $manager->set_post_value( 'blogname', 'Hello Surface' );
     552                $manager->save_changeset_post( array( 'status' => 'auto-draft' ) );
     553
     554                $manager->set_post_value( 'blogname', 'Hello World' );
     555                $manager->save_changeset_post( array( 'status' => 'draft' ) );
     556                $this->assertTrue( wp_revisions_enabled( get_post( $manager->changeset_post_id() ) ) );
     557
     558                $manager->set_post_value( 'blogname', 'Hello Solar System' );
     559                $manager->save_changeset_post( array( 'status' => 'draft' ) );
     560
     561                $manager->set_post_value( 'blogname', 'Hello Galaxy' );
     562                $manager->save_changeset_post( array( 'status' => 'draft' ) );
     563                $this->assertCount( 3, wp_get_post_revisions( $manager->changeset_post_id() ) );
     564        }
     565
     566        /**
     567         * Call count for customize_changeset_save_data filter.
     568         *
     569         * @var int
     570         */
     571        protected $customize_changeset_save_data_call_count = 0;
     572
     573        /**
     574         * Filter customize_changeset_save_data.
     575         *
     576         * @param array $data    Data.
     577         * @param array $context Context.
     578         * @returns array Data.
     579         */
     580        function filter_customize_changeset_save_data( $data, $context ) {
     581                $this->customize_changeset_save_data_call_count += 1;
     582                $this->assertInternalType( 'array', $data );
     583                $this->assertInternalType( 'array', $context );
     584                $this->assertArrayHasKey( 'uuid', $context );
     585                $this->assertArrayHasKey( 'title', $context );
     586                $this->assertArrayHasKey( 'status', $context );
     587                $this->assertArrayHasKey( 'date_gmt', $context );
     588                $this->assertArrayHasKey( 'post_id', $context );
     589                $this->assertArrayHasKey( 'previous_data', $context );
     590                $this->assertArrayHasKey( 'manager', $context );
     591                return $data;
     592        }
     593
     594        /**
     595         * Return illegal error.
     596         *
     597         * @return WP_Error Error.
     598         */
     599        function return_illegal_error() {
     600                return new WP_Error( 'illegal' );
     601        }
     602
     603        /**
     604         * Test WP_Customize_Manager::save_changeset_post().
     605         *
     606         * @ticket 30937
     607         * @covers WP_Customize_Manager::save_changeset_post()
     608         * @covers WP_Customize_Manager::update_stashed_theme_mod_settings()
     609         */
     610        function test_save_changeset_post_with_theme_activation() {
     611                wp_set_current_user( self::$admin_user_id );
     612
     613                $stashed_theme_mods = array(
     614                        'twentyfifteen' => array(
     615                                'background_color' => array(
     616                                        'value' => '#123456',
     617                                ),
     618                        ),
     619                );
     620                update_option( 'customize_stashed_theme_mods', $stashed_theme_mods );
     621                $uuid = wp_generate_uuid4();
     622                $manager = new WP_Customize_Manager( array(
     623                        'changeset_uuid' => $uuid,
     624                        'theme' => 'twentyfifteen',
     625                ) );
     626                $manager->register_controls();
     627                $GLOBALS['wp_customize'] = $manager;
     628
     629                $manager->set_post_value( 'blogname', 'Hello 2015' );
     630                $post_values = $manager->unsanitized_post_values();
     631                $manager->save_changeset_post( array( 'status' => 'publish' ) ); // Activate.
     632
     633                $this->assertEquals( '#123456', $post_values['background_color'] );
     634                $this->assertEquals( 'twentyfifteen', get_stylesheet() );
     635                $this->assertEquals( 'Hello 2015', get_option( 'blogname' ) );
     636        }
     637
     638        /**
     639         * Test WP_Customize_Manager::is_cross_domain().
     640         *
     641         * @ticket 30937
     642         * @covers WP_Customize_Manager::is_cross_domain()
     643         */
     644        function test_is_cross_domain() {
     645                $wp_customize = new WP_Customize_Manager();
     646
     647                update_option( 'home', 'http://example.com' );
     648                update_option( 'siteurl', 'http://example.com' );
     649                $this->assertFalse( $wp_customize->is_cross_domain() );
     650
     651                update_option( 'home', 'http://example.com' );
     652                update_option( 'siteurl', 'https://admin.example.com' );
     653                $this->assertTrue( $wp_customize->is_cross_domain() );
     654        }
     655
     656        /**
     657         * Test WP_Customize_Manager::get_allowed_urls().
     658         *
     659         * @ticket 30937
     660         * @covers WP_Customize_Manager::get_allowed_urls()
     661         */
     662        function test_get_allowed_urls() {
     663                $wp_customize = new WP_Customize_Manager();
     664                $this->assertFalse( is_ssl() );
     665                $this->assertFalse( $wp_customize->is_cross_domain() );
     666                $allowed = $wp_customize->get_allowed_urls();
     667                $this->assertEquals( $allowed, array( home_url( '/', 'http' ) ) );
     668
     669                add_filter( 'customize_allowed_urls', array( $this, 'filter_customize_allowed_urls' ) );
     670                $allowed = $wp_customize->get_allowed_urls();
     671                $this->assertEqualSets( $allowed, array( 'http://headless.example.com/', home_url( '/', 'http' ) ) );
     672        }
     673
     674        /**
     675         * Callback for customize_allowed_urls filter.
     676         *
     677         * @param array $urls URLs.
     678         * @return array URLs.
     679         */
     680        function filter_customize_allowed_urls( $urls ) {
     681                $urls[] = 'http://headless.example.com/';
     682                return $urls;
    56683        }
    57684
     
    91718         * @ticket 30988
    92719         */
    93         function test_unsanitized_post_values() {
     720        function test_unsanitized_post_values_from_input() {
     721                wp_set_current_user( self::$admin_user_id );
    94722                $manager = $this->manager;
    95723
     
    101729                $post_values = $manager->unsanitized_post_values();
    102730                $this->assertEquals( $customized, $post_values );
     731                $this->assertEmpty( $manager->unsanitized_post_values( array( 'exclude_post_data' => true ) ) );
     732
     733                $manager->set_post_value( 'foo', 'BAR' );
     734                $post_values = $manager->unsanitized_post_values();
     735                $this->assertEquals( 'BAR', $post_values['foo'] );
     736                $this->assertEmpty( $manager->unsanitized_post_values( array( 'exclude_post_data' => true ) ) );
     737
     738                // If user is unprivileged, the post data is ignored.
     739                wp_set_current_user( 0 );
     740                $this->assertEmpty( $manager->unsanitized_post_values() );
     741        }
     742
     743        /**
     744         * Test WP_Customize_Manager::unsanitized_post_values().
     745         *
     746         * @ticket 30937
     747         * @covers WP_Customize_Manager::unsanitized_post_values()
     748         */
     749        function test_unsanitized_post_values_with_changeset_and_stashed_theme_mods() {
     750                wp_set_current_user( self::$admin_user_id );
     751
     752                $stashed_theme_mods = array(
     753                        'twentyfifteen' => array(
     754                                'background_color' => array(
     755                                        'value' => '#000000',
     756                                ),
     757                        ),
     758                );
     759                $stashed_theme_mods[ get_stylesheet() ] = array(
     760                        'background_color' => array(
     761                                'value' => '#FFFFFF',
     762                        ),
     763                );
     764                update_option( 'customize_stashed_theme_mods', $stashed_theme_mods );
     765
     766                $post_values = array(
     767                        'blogdescription' => 'Post Input Tagline',
     768                );
     769                $_POST['customized'] = wp_slash( wp_json_encode( $post_values ) );
     770
     771                $uuid = wp_generate_uuid4();
     772                $changeset_data = array(
     773                        'blogname' => array(
     774                                'value' => 'Changeset Title',
     775                        ),
     776                        'blogdescription' => array(
     777                                'value' => 'Changeset Tagline',
     778                        ),
     779                );
     780                $this->factory()->post->create( array(
     781                        'post_type' => 'customize_changeset',
     782                        'post_status' => 'auto-draft',
     783                        'post_name' => $uuid,
     784                        'post_content' => wp_json_encode( $changeset_data ),
     785                ) );
     786
     787                $manager = new WP_Customize_Manager( array(
     788                        'changeset_uuid' => $uuid,
     789                ) );
     790                $this->assertTrue( $manager->is_theme_active() );
     791
     792                $this->assertArrayNotHasKey( 'background_color', $manager->unsanitized_post_values() );
     793
     794                $this->assertEquals(
     795                        array(
     796                                'blogname' => 'Changeset Title',
     797                                'blogdescription' => 'Post Input Tagline',
     798                        ),
     799                        $manager->unsanitized_post_values()
     800                );
     801                $this->assertEquals(
     802                        array(
     803                                'blogdescription' => 'Post Input Tagline',
     804                        ),
     805                        $manager->unsanitized_post_values( array( 'exclude_changeset' => true ) )
     806                );
     807
     808                $manager->set_post_value( 'blogdescription', 'Post Override Tagline' );
     809                $this->assertEquals(
     810                        array(
     811                                'blogname' => 'Changeset Title',
     812                                'blogdescription' => 'Post Override Tagline',
     813                        ),
     814                        $manager->unsanitized_post_values()
     815                );
     816
     817                $this->assertEquals(
     818                        array(
     819                                'blogname' => 'Changeset Title',
     820                                'blogdescription' => 'Changeset Tagline',
     821                        ),
     822                        $manager->unsanitized_post_values( array( 'exclude_post_data' => true ) )
     823                );
     824
     825                $this->assertEmpty( $manager->unsanitized_post_values( array( 'exclude_post_data' => true, 'exclude_changeset' => true ) ) );
     826
     827                // Test unstashing theme mods.
     828                $manager = new WP_Customize_Manager( array(
     829                        'changeset_uuid' => $uuid,
     830                        'theme' => 'twentyfifteen',
     831                ) );
     832                $this->assertFalse( $manager->is_theme_active() );
     833                $values = $manager->unsanitized_post_values( array( 'exclude_post_data' => true, 'exclude_changeset' => true ) );
     834                $this->assertNotEmpty( $values );
     835                $this->assertArrayHasKey( 'background_color', $values );
     836                $this->assertEquals( '#000000', $values['background_color'] );
     837
     838                $values = $manager->unsanitized_post_values( array( 'exclude_post_data' => false, 'exclude_changeset' => false ) );
     839                $this->assertArrayHasKey( 'background_color', $values );
     840                $this->assertArrayHasKey( 'blogname', $values );
     841                $this->assertArrayHasKey( 'blogdescription', $values );
    103842        }
    104843
     
    109848         */
    110849        function test_post_value() {
     850                wp_set_current_user( self::$admin_user_id );
    111851                $posted_settings = array(
    112852                        'foo' => 'OOF',
     
    132872         */
    133873        function test_invalid_post_value() {
     874                wp_set_current_user( self::$admin_user_id );
    134875                $default_value = 'foo_default';
    135876                $setting = $this->manager->add_setting( 'foo', array(
     
    197938         */
    198939        function test_post_value_validation_sanitization_order() {
     940                wp_set_current_user( self::$admin_user_id );
    199941                $default_value = '0';
    200942                $setting = $this->manager->add_setting( 'numeric', array(
     
    241983         */
    242984        function test_validate_setting_values() {
     985                wp_set_current_user( self::$admin_user_id );
    243986                $setting = $this->manager->add_setting( 'foo', array(
    244987                        'validate_callback' => array( $this, 'filter_customize_validate_foo' ),
     
    3061049
    3071050        /**
     1051         * Test WP_Customize_Manager::validate_setting_values().
     1052         *
     1053         * @ticket 30937
     1054         * @covers WP_Customize_Manager::validate_setting_values()
     1055         */
     1056        function test_validate_setting_values_args() {
     1057                wp_set_current_user( self::$admin_user_id );
     1058                $this->manager->register_controls();
     1059
     1060                $validities = $this->manager->validate_setting_values( array( 'unknown' => 'X' ) );
     1061                $this->assertEmpty( $validities );
     1062
     1063                $validities = $this->manager->validate_setting_values( array( 'unknown' => 'X' ), array( 'validate_existence' => false ) );
     1064                $this->assertEmpty( $validities );
     1065
     1066                $validities = $this->manager->validate_setting_values( array( 'unknown' => 'X' ), array( 'validate_existence' => true ) );
     1067                $this->assertNotEmpty( $validities );
     1068                $this->assertArrayHasKey( 'unknown', $validities );
     1069                $error = $validities['unknown'];
     1070                $this->assertInstanceOf( 'WP_Error', $error );
     1071                $this->assertEquals( 'unrecognized', $error->get_error_code() );
     1072
     1073                $this->manager->get_setting( 'blogname' )->capability = 'do_not_allow';
     1074                $validities = $this->manager->validate_setting_values( array( 'blogname' => 'X' ), array( 'validate_capability' => false ) );
     1075                $this->assertArrayHasKey( 'blogname', $validities );
     1076                $this->assertTrue( $validities['blogname'] );
     1077                $validities = $this->manager->validate_setting_values( array( 'blogname' => 'X' ), array( 'validate_capability' => true ) );
     1078                $this->assertArrayHasKey( 'blogname', $validities );
     1079                $error = $validities['blogname'];
     1080                $this->assertInstanceOf( 'WP_Error', $error );
     1081                $this->assertEquals( 'unauthorized', $error->get_error_code() );
     1082        }
     1083
     1084        /**
    3081085         * Add a length constraint to a setting.
    3091086         *
     
    3291106         */
    3301107        function test_validate_setting_values_validation_sanitization_order() {
     1108                wp_set_current_user( self::$admin_user_id );
    3311109                $setting = $this->manager->add_setting( 'numeric', array(
    3321110                        'validate_callback' => array( $this, 'filter_customize_validate_numeric' ),
     
    3701148         */
    3711149        function test_set_post_value() {
     1150                wp_set_current_user( self::$admin_user_id );
    3721151                $this->manager->add_setting( 'foo', array(
    3731152                        'sanitize_callback' => array( $this, 'sanitize_foo_for_test_set_post_value' ),
     
    4741253                $this->assertFalse( $this->manager->has_published_pages() );
    4751254
    476                 wp_set_current_user( $this->factory()->user->create( array( 'role' => 'editor' ) ) );
     1255                wp_set_current_user( self::$admin_user_id );
    4771256                $this->manager->nav_menus->customize_register();
    4781257                $setting_id = 'nav_menus_created_posts';
     
    4931272         */
    4941273        function test_register_dynamic_settings() {
     1274                wp_set_current_user( self::$admin_user_id );
    4951275                $posted_settings = array(
    4961276                        'foo' => 'OOF',
     
    5921372                $this->assertEquals( home_url( '/' ), $this->manager->get_return_url() );
    5931373
    594                 wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     1374                wp_set_current_user( self::$admin_user_id );
    5951375                $this->assertTrue( current_user_can( 'edit_theme_options' ) );
    5961376                $this->assertEquals( home_url( '/' ), $this->manager->get_return_url() );
     
    6851465         */
    6861466        function test_customize_pane_settings() {
    687                 wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     1467                wp_set_current_user( self::$admin_user_id );
    6881468                $this->manager->register_controls();
    6891469                $this->manager->prepare_controls();
     
    7071487                $this->assertNotEmpty( $data );
    7081488
    709                 $this->assertEqualSets( array( 'theme', 'url', 'browser', 'panels', 'sections', 'nonce', 'autofocus', 'documentTitleTmpl', 'previewableDevices' ), array_keys( $data ) );
     1489                $this->assertEqualSets( array( 'theme', 'url', 'browser', 'panels', 'sections', 'nonce', 'autofocus', 'documentTitleTmpl', 'previewableDevices', 'changeset', 'timeouts' ), array_keys( $data ) );
    7101490                $this->assertEquals( $autofocus, $data['autofocus'] );
    7111491                $this->assertArrayHasKey( 'save', $data['nonce'] );
     
    7191499         */
    7201500        function test_customize_preview_settings() {
    721                 wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     1501                wp_set_current_user( self::$admin_user_id );
    7221502                $this->manager->register_controls();
    7231503                $this->manager->prepare_controls();
     
    7411521                $this->assertArrayHasKey( 'nonce', $settings );
    7421522                $this->assertArrayHasKey( '_dirty', $settings );
     1523                $this->assertArrayHasKey( 'timeouts', $settings );
     1524                $this->assertArrayHasKey( 'changeset', $settings );
    7431525
    7441526                $this->assertArrayHasKey( 'preview', $settings['nonce'] );
    745                 $this->assertEquals( array( 'foo' ), $settings['_dirty'] );
    7461527        }
    7471528
     
    8151596                $manager->register_controls();
    8161597                $section_id = 'foo-section';
    817                 wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     1598                wp_set_current_user( self::$admin_user_id );
    8181599                $manager->add_section( $section_id, array(
    8191600                        'title'      => 'Section',
     
    8461627        function test_add_section_return_instance() {
    8471628                $manager = new WP_Customize_Manager();
    848                 wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     1629                wp_set_current_user( self::$admin_user_id );
    8491630
    8501631                $section_id = 'foo-section';
     
    8731654        function test_add_setting_return_instance() {
    8741655                $manager = new WP_Customize_Manager();
    875                 wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     1656                wp_set_current_user( self::$admin_user_id );
    8761657
    8771658                $setting_id = 'foo-setting';
     
    9441725        function test_add_panel_return_instance() {
    9451726                $manager = new WP_Customize_Manager();
    946                 wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     1727                wp_set_current_user( self::$admin_user_id );
    9471728
    9481729                $panel_id = 'foo-panel';
     
    9711752                $manager = new WP_Customize_Manager();
    9721753                $section_id = 'foo-section';
    973                 wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
     1754                wp_set_current_user( self::$admin_user_id );
    9741755                $manager->add_section( $section_id, array(
    9751756                        'title'    => 'Section',
  • trunk/tests/phpunit/tests/customize/selective-refresh-ajax.php

    r37700 r38810  
    141141
    142142        /**
    143          * Make sure that the Customizer "signature" is not included in partial render responses.
    144          *
    145          * @see WP_Customize_Selective_Refresh::handle_render_partials_request()
    146          */
    147         function test_handle_render_partials_request_removes_customize_signature() {
    148                 $this->setup_valid_render_partials_request_environment();
    149                 $this->assertTrue( is_customize_preview() );
    150                 $this->assertEquals( 1000, has_action( 'shutdown', array( $this->wp_customize, 'customize_preview_signature' ) ) );
    151                 ob_start();
    152                 try {
    153                         $this->selective_refresh->handle_render_partials_request();
    154                 } catch ( WPDieException $e ) {
    155                         unset( $e );
    156                 }
    157                 ob_end_clean();
    158                 $this->assertFalse( has_action( 'shutdown', array( $this->wp_customize, 'customize_preview_signature' ) ) );
    159         }
    160 
    161         /**
    162143         * Test WP_Customize_Selective_Refresh::handle_render_partials_request() for an unrecognized partial.
    163144         *
  • trunk/tests/phpunit/tests/customize/setting.php

    r38763 r38810  
    9898         */
    9999        function test_preview_standard_types_non_multidimensional() {
     100                wp_set_current_user( $this->factory()->user->create( array( 'role' => 'administrator' ) ) );
    100101                $_POST['customized'] = wp_slash( wp_json_encode( $this->post_data_overrides ) );
    101102
     
    176177         */
    177178        function test_preview_standard_types_multidimensional() {
     179                wp_set_current_user( $this->factory()->user->create( array( 'role' => 'administrator' ) ) );
    178180                $_POST['customized'] = wp_slash( wp_json_encode( $this->post_data_overrides ) );
    179181
     
    315317         */
    316318        function test_preview_custom_type() {
     319                wp_set_current_user( $this->factory()->user->create( array( 'role' => 'administrator' ) ) );
    317320                $type = 'custom_type';
    318321                $post_data_overrides = array(
     
    479482         */
    480483        function test_is_current_blog_previewed() {
     484                wp_set_current_user( $this->factory()->user->create( array( 'role' => 'administrator' ) ) );
    481485                $type = 'option';
    482486                $name = 'blogname';
     
    503507                }
    504508
     509                wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
    505510                $type = 'option';
    506511                $name = 'blogdescription';
     
    648653         */
    649654        public function test_multidimensional_value_when_previewed() {
     655                wp_set_current_user( $this->factory()->user->create( array( 'role' => 'administrator' ) ) );
    650656                WP_Customize_Setting::reset_aggregated_multidimensionals();
    651657
  • trunk/tests/phpunit/tests/functions.php

    r38685 r38810  
    881881                $this->assertEquals( WP_MAX_MEMORY_LIMIT, $ini_limit_after );
    882882        }
     883
     884        /**
     885         * Tests wp_generate_uuid4().
     886         *
     887         * @covers wp_generate_uuid4()
     888         * @ticket 38164
     889         */
     890        function test_wp_generate_uuid4() {
     891                $uuids = array();
     892                for ( $i = 0; $i < 20; $i += 1 ) {
     893                        $uuid = wp_generate_uuid4();
     894                        $this->assertRegExp( '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $uuid );
     895                        $uuids[] = $uuid;
     896                }
     897
     898                $unique_uuids = array_unique( $uuids );
     899                $this->assertEquals( $uuids, $unique_uuids );
     900        }
    883901}
  • trunk/tests/phpunit/tests/post.php

    r38398 r38810  
    12411241        }
    12421242
     1243        /**
     1244         * Test ensuring that the post_name (UUID) is preserved when wp_insert_post()/wp_update_post() is called.
     1245         *
     1246         * @see _wp_customize_changeset_filter_insert_post_data()
     1247         * @ticket 30937
     1248         */
     1249        function test_wp_insert_post_for_customize_changeset_should_not_drop_post_name() {
     1250
     1251                $this->assertEquals( 10, has_filter( 'wp_insert_post_data', '_wp_customize_changeset_filter_insert_post_data' ) );
     1252
     1253                $changeset_data = array(
     1254                        'blogname' => array(
     1255                                'value' => 'Hello World',
     1256                        ),
     1257                );
     1258
     1259                wp_set_current_user( $this->factory()->user->create( array( 'role' => 'contributor' ) ) );
     1260
     1261                $uuid = wp_generate_uuid4();
     1262                $post_id = wp_insert_post( array(
     1263                        'post_type' => 'customize_changeset',
     1264                        'post_name' => strtoupper( $uuid ),
     1265                        'post_content' => wp_json_encode( $changeset_data ),
     1266                ) );
     1267                $this->assertEquals( $uuid, get_post( $post_id )->post_name, 'Expected lower-case UUID4 to be inserted.' );
     1268                $this->assertEquals( $changeset_data, json_decode( get_post( $post_id )->post_content, true ) );
     1269
     1270                $changeset_data['blogname']['value'] = 'Hola Mundo';
     1271                wp_update_post( array(
     1272                        'ID' => $post_id,
     1273                        'post_status' => 'draft',
     1274                        'post_content' => wp_json_encode( $changeset_data ),
     1275                ) );
     1276                $this->assertEquals( $uuid, get_post( $post_id )->post_name, 'Expected post_name to not have been dropped for drafts.' );
     1277                $this->assertEquals( $changeset_data, json_decode( get_post( $post_id )->post_content, true ) );
     1278
     1279                $changeset_data['blogname']['value'] = 'Hallo Welt';
     1280                wp_update_post( array(
     1281                        'ID' => $post_id,
     1282                        'post_status' => 'pending',
     1283                        'post_content' => wp_json_encode( $changeset_data ),
     1284                ) );
     1285                $this->assertEquals( $uuid, get_post( $post_id )->post_name, 'Expected post_name to not have been dropped for pending.' );
     1286                $this->assertEquals( $changeset_data, json_decode( get_post( $post_id )->post_content, true ) );
     1287        }
     1288
    12431289}
  • trunk/tests/qunit/fixtures/customize-settings.js

    r37476 r38810  
    148148                        'label': 'Enter mobile preview mode'
    149149                }
     150        },
     151        changeset: {
     152                status: '',
     153                uuid: '0c674ff4-c159-4e7a-beb4-cb830ae73979'
     154        },
     155        timeouts: {
     156                windowRefresh: 250,
     157                changesetAutoSave: 60000,
     158                keepAliveCheck: 2500,
     159                reflowPaneContents: 100,
     160                previewFrameSensitivity: 2000
    150161        }
    151162};
  • trunk/tests/qunit/index.html

    r38672 r38810  
    317317
    318318                <div hidden>
     319                        <div id="customize-preview"></div>
     320                </div>
     321
     322                <div hidden>
    319323                        <div id="available-menu-items" class="accordion-container">
    320324                        <div class="customize-section-title">
  • trunk/tests/qunit/wp-admin/js/customize-base.js

    r38513 r38810  
    185185                assert.equal( null, notification.data );
    186186        } );
     187
     188        module( 'Customize Base: utils.parseQueryString' );
     189        test( 'wp.customize.utils.parseQueryString works', function( assert ) {
     190                var queryParams;
     191                queryParams = wp.customize.utils.parseQueryString( 'a=1&b=2' );
     192                assert.ok( _.isEqual( queryParams, { a: '1', b: '2' } ) );
     193
     194                queryParams = wp.customize.utils.parseQueryString( 'a+b=1&b=Hello%20World' );
     195                assert.ok( _.isEqual( queryParams, { 'a_b': '1', b: 'Hello World' } ) );
     196
     197                queryParams = wp.customize.utils.parseQueryString( 'a%20b=1&b=Hello+World' );
     198                assert.ok( _.isEqual( queryParams, { 'a_b': '1', b: 'Hello World' } ) );
     199
     200                queryParams = wp.customize.utils.parseQueryString( 'a=1&b' );
     201                assert.ok( _.isEqual( queryParams, { 'a': '1', b: null } ) );
     202
     203                queryParams = wp.customize.utils.parseQueryString( 'a=1&b=' );
     204                assert.ok( _.isEqual( queryParams, { 'a': '1', b: '' } ) );
     205        } );
    187206});
  • trunk/tests/qunit/wp-admin/js/customize-controls.js

    r38648 r38810  
    1 /* global wp, test, ok, equal, module */
     1/* global JSON, wp, test, ok, equal, module */
    22
    33wp.customize.settingConstructor.abbreviation = wp.customize.Setting.extend({
     
    559559                equal( wp.customize.control( controlId ), controlsForSettings['fixture-setting'][0] );
    560560        } );
     561
     562        module( 'Customize Controls wp.customize.dirtyValues' );
     563        test( 'dirtyValues() returns expected values', function() {
     564                wp.customize.each( function( setting ) {
     565                        setting._dirty = false;
     566                } );
     567                ok( _.isEmpty( wp.customize.dirtyValues() ) );
     568                ok( _.isEmpty( wp.customize.dirtyValues( { unsaved: false } ) ) );
     569
     570                wp.customize( 'fixture-setting' )._dirty = true;
     571                ok( ! _.isEmpty( wp.customize.dirtyValues() ) );
     572                ok( _.isEmpty( wp.customize.dirtyValues( { unsaved: true } ) ) );
     573
     574                wp.customize( 'fixture-setting' ).set( 'Modified' );
     575                ok( ! _.isEmpty( wp.customize.dirtyValues() ) );
     576                ok( ! _.isEmpty( wp.customize.dirtyValues( { unsaved: true } ) ) );
     577                equal( 'Modified', wp.customize.dirtyValues()['fixture-setting'] );
     578        } );
     579
     580        module( 'Customize Controls: wp.customize.requestChangesetUpdate()' );
     581        test( 'requestChangesetUpdate makes request and returns promise', function( assert ) {
     582                var request, originalBeforeSetup = jQuery.ajaxSettings.beforeSend;
     583
     584                jQuery.ajaxSetup( {
     585                        beforeSend: function( e, data ) {
     586                                var queryParams, changesetData;
     587                                queryParams = wp.customize.utils.parseQueryString( data.data );
     588
     589                                assert.equal( 'customize_save', queryParams.action );
     590                                assert.ok( ! _.isUndefined( queryParams.customize_changeset_data ) );
     591                                assert.ok( ! _.isUndefined( queryParams.nonce ) );
     592                                assert.ok( ! _.isUndefined( queryParams.customize_theme ) );
     593                                assert.equal( wp.customize.settings.changeset.uuid, queryParams.customize_changeset_uuid );
     594                                assert.equal( 'on', queryParams.wp_customize );
     595
     596                                changesetData = JSON.parse( queryParams.customize_changeset_data );
     597                                assert.ok( ! _.isUndefined( changesetData.additionalSetting ) );
     598                                assert.ok( ! _.isUndefined( changesetData['fixture-setting'] ) );
     599
     600                                assert.equal( 'additionalValue', changesetData.additionalSetting.value );
     601                                assert.equal( 'requestChangesetUpdate', changesetData['fixture-setting'].value );
     602
     603                                // Prevent Ajax request from completing.
     604                                return false;
     605                        }
     606                } );
     607
     608                wp.customize.each( function( setting ) {
     609                        setting._dirty = false;
     610                } );
     611
     612                request = wp.customize.requestChangesetUpdate();
     613                assert.equal( 'resolved', request.state());
     614                request.done( function( data ) {
     615                        assert.ok( _.isEqual( {}, data ) );
     616                } );
     617
     618                wp.customize( 'fixture-setting' ).set( 'requestChangesetUpdate' );
     619
     620                request = wp.customize.requestChangesetUpdate( {
     621                        additionalSetting: {
     622                                value: 'additionalValue'
     623                        }
     624                } );
     625
     626                request.always( function( data ) {
     627                        assert.equal( 'canceled', data.statusText );
     628                        jQuery.ajaxSetup( { beforeSend: originalBeforeSetup } );
     629                } );
     630        } );
    561631});
Note: See TracChangeset for help on using the changeset viewer.