Make WordPress Core


Ignore:
Timestamp:
10/04/2017 12:19:16 AM (9 years ago)
Author:
westonruter
Message:

File Editors: Introduce sandboxed live editing of PHP files with rollbacks for both themes and plugins.

  • Edits to active plugins which cause PHP fatal errors will no longer auto-deactivate the plugin. Supersedes #39766.
  • Introduce sandboxed PHP file edits for active themes, preventing accidental whitescreening of a user's site when introducing a fatal error.
  • After writing a change to a PHP file for an active theme or plugin, perform loopback requests on the file editor admin screens and the homepage to check for fatal errors. If a fatal error is encountered, roll back the edited file and display the error to the user to fix and try again.
  • Introduce a secure way to scrape PHP fatal errors from a site via wp_start_scraping_edited_file_errors() and wp_finalize_scraping_edited_file_errors().
  • Moves file modifications from theme-editor.php and plugin-editor.php to common wp_edit_theme_plugin_file() function.
  • Refactor themes and plugin editors to submit file changes via Ajax instead of doing full page refreshes when JS is available.
  • Use get method for theme/plugin dropdowns.
  • Improve styling of plugin editors, including width of plugin/theme dropdowns.
  • Improve notices API for theme/plugin editor JS component.
  • Strip common base directory from plugin file list. See #24048.
  • Factor out functions to list editable file types in wp_get_theme_file_editable_extensions() and wp_get_plugin_file_editable_extensions().
  • Scroll to line in editor that has linting error when attempting to save. See #41886.
  • Add checkbox to dismiss lint errors to proceed with saving. See #41887.
  • Only style the Update File button as disabled instead of actually disabling it for accessibility reasons.
  • Ensure that value from CodeMirror is used instead of textarea when CodeMirror is present.
  • Add "Are you sure?" check when leaving editor when there are unsaved changes.

Supersedes [41560].
See #39766, #24048, #41886.
Props westonruter, Clorith, melchoyce, johnbillion, jjj, jdgrimes, azaozz.
Fixes #21622, #41887.

File:
1 edited

Legend:

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

    r41457 r41721  
    7171 *
    7272 * @global array $wp_file_descriptions Theme file descriptions.
    73  * @global array $allowed_files        List of allowed files. 
     73 * @global array $allowed_files        List of allowed files.
    7474 * @param string $file Filesystem path or filename
    7575 * @return string Description of file from $wp_file_descriptions or basename of $file if description doesn't exist.
     
    152152        return $files;
    153153}
     154
     155/**
     156 * Get list of file extensions that are editable in plugins.
     157 *
     158 * @since 4.9.0
     159 *
     160 * @param string $plugin Plugin.
     161 * @return array File extensions.
     162 */
     163function wp_get_plugin_file_editable_extensions( $plugin ) {
     164
     165        $editable_extensions = array(
     166                'bash',
     167                'conf',
     168                'css',
     169                'diff',
     170                'htm',
     171                'html',
     172                'http',
     173                'inc',
     174                'include',
     175                'js',
     176                'json',
     177                'jsx',
     178                'less',
     179                'md',
     180                'patch',
     181                'php',
     182                'php3',
     183                'php4',
     184                'php5',
     185                'php7',
     186                'phps',
     187                'phtml',
     188                'sass',
     189                'scss',
     190                'sh',
     191                'sql',
     192                'svg',
     193                'text',
     194                'txt',
     195                'xml',
     196                'yaml',
     197                'yml',
     198        );
     199
     200        /**
     201         * Filters file type extensions editable in the plugin editor.
     202         *
     203         * @since 2.8.0
     204         * @since 4.9.0 Adds $plugin param.
     205         *
     206         * @param string $plugin Plugin file.
     207         * @param array $editable_extensions An array of editable plugin file extensions.
     208         */
     209        $editable_extensions = (array) apply_filters( 'editable_extensions', $editable_extensions, $plugin );
     210
     211        return $editable_extensions;
     212}
     213
     214/**
     215 * Get list of file extensions that are editable for a given theme.
     216 *
     217 * @param WP_Theme $theme Theme.
     218 * @return array File extensions.
     219 */
     220function wp_get_theme_file_editable_extensions( $theme ) {
     221
     222        $default_types = array(
     223                'bash',
     224                'conf',
     225                'css',
     226                'diff',
     227                'htm',
     228                'html',
     229                'http',
     230                'inc',
     231                'include',
     232                'js',
     233                'json',
     234                'jsx',
     235                'less',
     236                'md',
     237                'patch',
     238                'php',
     239                'php3',
     240                'php4',
     241                'php5',
     242                'php7',
     243                'phps',
     244                'phtml',
     245                'sass',
     246                'scss',
     247                'sh',
     248                'sql',
     249                'svg',
     250                'text',
     251                'txt',
     252                'xml',
     253                'yaml',
     254                'yml',
     255        );
     256
     257        /**
     258         * Filters the list of file types allowed for editing in the Theme editor.
     259         *
     260         * @since 4.4.0
     261         *
     262         * @param array    $default_types List of file types. Default types include 'php' and 'css'.
     263         * @param WP_Theme $theme         The current Theme object.
     264         */
     265        $file_types = apply_filters( 'wp_theme_editor_filetypes', $default_types, $theme );
     266
     267        // Ensure that default types are still there.
     268        return array_unique( array_merge( $file_types, $default_types ) );
     269}
     270
     271/**
     272 * Print file editor templates (for plugins and themes).
     273 *
     274 * @since 4.9.0
     275 */
     276function wp_print_file_editor_templates() {
     277        ?>
     278        <script type="text/html" id="tmpl-wp-file-editor-notice">
     279                <div class="notice inline notice-{{ data.type || 'info' }} {{ data.alt ? 'notice-alt' : '' }} {{ data.dismissible ? 'is-dismissible' : '' }} {{ data.classes || '' }}">
     280                        <# if ( 'php_error' === data.code ) { #>
     281                                <p>
     282                                        <?php
     283                                        printf(
     284                                                /* translators: %$1s is line number and %1$s is file path. */
     285                                                __( 'Your PHP code changes were rolled back due to an error on line %1$s of file %2$s. Please fix and try saving again.' ),
     286                                                '{{ data.line }}',
     287                                                '{{ data.file }}'
     288                                        );
     289                                        ?>
     290                                </p>
     291                                <pre>{{ data.message }}</pre>
     292                        <# } else if ( 'file_not_writable' === data.code ) { #>
     293                                <p><?php _e( 'You need to make this file writable before you can save your changes. See <a href="https://codex.wordpress.org/Changing_File_Permissions">the Codex</a> for more information.' ); ?></p>
     294                        <# } else { #>
     295                                <p>{{ data.message || data.code }}</p>
     296
     297                                <# if ( 'lint_errors' === data.code ) { #>
     298                                        <p>
     299                                                <# var elementId = 'el-' + String( Math.random() ); #>
     300                                                <input id="{{ elementId }}"  type="checkbox">
     301                                                <label for="{{ elementId }}"><?php _e( 'Update anyway, even though it might break your site?' ); ?></label>
     302                                        </p>
     303                                <# } #>
     304                        <# } #>
     305                        <# if ( data.dismissible ) { #>
     306                                <button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php _e( 'Dismiss' ); ?></span></button>
     307                        <# } #>
     308                </div>
     309        </script>
     310        <?php
     311}
     312
     313/**
     314 * Attempt to edit a file for a theme or plugin.
     315 *
     316 * When editing a PHP file, loopback requests will be made to the admin and the homepage
     317 * to attempt to see if there is a fatal error introduced. If so, the PHP change will be
     318 * reverted.
     319 *
     320 * @since 4.9.0
     321 *
     322 * @param array $args {
     323 *     Args. Note that all of the arg values are already unslashed. They are, however,
     324 *     coming straight from $_POST and are not validated or sanitized in any way.
     325 *
     326 *     @type string $file       Relative path to file.
     327 *     @type string $plugin     Plugin being edited.
     328 *     @type string $theme      Theme being edited.
     329 *     @type string $newcontent New content for the file.
     330 *     @type string $nonce      Nonce.
     331 * }
     332 * @return true|WP_Error True on success or `WP_Error` on failure.
     333 */
     334function wp_edit_theme_plugin_file( $args ) {
     335        if ( empty( $args['file'] ) ) {
     336                return new WP_Error( 'missing_file' );
     337        }
     338        $file = $args['file'];
     339        if ( 0 !== validate_file( $file ) ) {
     340                return new WP_Error( 'bad_file' );
     341        }
     342
     343        if ( ! isset( $args['newcontent'] ) ) {
     344                return new WP_Error( 'missing_content' );
     345        }
     346        $content = $args['newcontent'];
     347
     348        if ( ! isset( $args['nonce'] ) ) {
     349                return new WP_Error( 'missing_nonce' );
     350        }
     351
     352        $plugin = null;
     353        $theme = null;
     354        $real_file = null;
     355        if ( ! empty( $args['plugin'] ) ) {
     356                $plugin = $args['plugin'];
     357
     358                if ( ! current_user_can( 'edit_plugins' ) ) {
     359                        return new WP_Error( 'unauthorized', __( 'Sorry, you are not allowed to edit plugins for this site.' ) );
     360                }
     361
     362                if ( ! wp_verify_nonce( $args['nonce'], 'edit-plugin_' . $file ) ) {
     363                        return new WP_Error( 'nonce_failure' );
     364                }
     365
     366                if ( ! array_key_exists( $plugin, get_plugins() ) ) {
     367                        return new WP_Error( 'invalid_plugin' );
     368                }
     369
     370                if ( 0 !== validate_file( $file, get_plugin_files( $plugin ) ) ) {
     371                        return new WP_Error( 'bad_plugin_file_path', __( 'Sorry, that file cannot be edited.' ) );
     372                }
     373
     374                $editable_extensions = wp_get_plugin_file_editable_extensions( $plugin );
     375
     376                $real_file = WP_PLUGIN_DIR . '/' . $file;
     377
     378                $is_active = in_array(
     379                        $plugin,
     380                        (array) get_option( 'active_plugins', array() ),
     381                        true
     382                );
     383
     384        } elseif ( ! empty( $args['theme'] ) ) {
     385                $stylesheet = $args['theme'];
     386                if ( 0 !== validate_file( $stylesheet ) ) {
     387                        return new WP_Error( 'bad_theme_path' );
     388                }
     389
     390                if ( ! current_user_can( 'edit_themes' ) ) {
     391                        return new WP_Error( 'unauthorized', __( 'Sorry, you are not allowed to edit templates for this site.' ) );
     392                }
     393
     394                $theme = wp_get_theme( $stylesheet );
     395                if ( ! $theme->exists() ) {
     396                        return new WP_Error( 'non_existent_theme', __( 'The requested theme does not exist.' ) );
     397                }
     398
     399                $real_file = $theme->get_stylesheet_directory() . '/' . $file;
     400                if ( ! wp_verify_nonce( $args['nonce'], 'edit-theme_' . $real_file . $stylesheet ) ) {
     401                        return new WP_Error( 'nonce_failure' );
     402                }
     403
     404                if ( $theme->errors() && 'theme_no_stylesheet' === $theme->errors()->get_error_code() ) {
     405                        return new WP_Error(
     406                                'theme_no_stylesheet',
     407                                __( 'The requested theme does not exist.' ) . ' ' . $theme->errors()->get_error_message()
     408                        );
     409                }
     410
     411                $editable_extensions = wp_get_theme_file_editable_extensions( $theme );
     412
     413                $allowed_files = array();
     414                foreach ( $editable_extensions as $type ) {
     415                        switch ( $type ) {
     416                                case 'php':
     417                                        $allowed_files = array_merge( $allowed_files, $theme->get_files( 'php', 1 ) );
     418                                        break;
     419                                case 'css':
     420                                        $style_files = $theme->get_files( 'css' );
     421                                        $allowed_files['style.css'] = $style_files['style.css'];
     422                                        $allowed_files = array_merge( $allowed_files, $style_files );
     423                                        break;
     424                                default:
     425                                        $allowed_files = array_merge( $allowed_files, $theme->get_files( $type ) );
     426                                        break;
     427                        }
     428                }
     429
     430                if ( 0 !== validate_file( $real_file, $allowed_files ) ) {
     431                        return new WP_Error( 'disallowed_theme_file', __( 'Sorry, that file cannot be edited.' ) );
     432                }
     433
     434                $is_active = ( get_stylesheet() === $stylesheet || get_template() === $stylesheet );
     435        } else {
     436                return new WP_Error( 'missing_theme_or_plugin' );
     437        }
     438
     439        // Ensure file is real.
     440        if ( ! is_file( $real_file ) ) {
     441                return new WP_Error( 'file_does_not_exist', __( 'No such file exists! Double check the name and try again.' ) );
     442        }
     443
     444        // Ensure file extension is allowed.
     445        $extension = null;
     446        if ( preg_match( '/\.([^.]+)$/', $real_file, $matches ) ) {
     447                $extension = strtolower( $matches[1] );
     448                if ( ! in_array( $extension, $editable_extensions, true ) ) {
     449                        return new WP_Error( 'illegal_file_type', __( 'Files of this type are not editable.' ) );
     450                }
     451        }
     452
     453        $previous_content = file_get_contents( $real_file );
     454
     455        if ( ! is_writeable( $real_file ) ) {
     456                return new WP_Error( 'file_not_writable' );
     457        }
     458
     459        $f = fopen( $real_file, 'w+' );
     460        if ( false === $f ) {
     461                return new WP_Error( 'file_not_writable' );
     462        }
     463
     464        $written = fwrite( $f, $content );
     465        fclose( $f );
     466        if ( false === $written ) {
     467                return new WP_Error( 'unable_to_write', __( 'Unable to write to file.' ) );
     468        }
     469        if ( 'php' === $extension && function_exists( 'opcache_invalidate' ) ) {
     470                opcache_invalidate( $real_file, true );
     471        }
     472
     473        if ( $is_active && 'php' === $extension ) {
     474
     475                $scrape_key = md5( rand() );
     476                $transient = 'scrape_key_' . $scrape_key;
     477                $scrape_nonce = strval( rand() );
     478                set_transient( $transient, $scrape_nonce, 60 ); // It shouldn't take more than 60 seconds to make the two loopback requests.
     479
     480                $cookies = wp_unslash( $_COOKIE );
     481                $scrape_params = array(
     482                        'wp_scrape_key' => $scrape_key,
     483                        'wp_scrape_nonce' => $scrape_nonce,
     484                );
     485                $headers = array(
     486                        'Cache-Control' => 'no-cache',
     487                );
     488
     489                $needle = "###### begin_scraped_error:$scrape_key ######";
     490
     491                // Attempt loopback request to editor to see if user just whitescreened themselves.
     492                if ( $plugin ) {
     493                        $url = add_query_arg( compact( 'plugin', 'file' ), admin_url( 'plugin-editor.php' ) );
     494                } elseif ( isset( $stylesheet ) ) {
     495                        $url = add_query_arg(
     496                                array(
     497                                        'theme' => $stylesheet,
     498                                        'file' => $file,
     499                                ),
     500                                admin_url( 'theme-editor.php' )
     501                        );
     502                } else {
     503                        $url = admin_url();
     504                }
     505                $url = add_query_arg( $scrape_params, $url );
     506                $r = wp_remote_get( $url, compact( 'cookies', 'headers' ) );
     507                $body = wp_remote_retrieve_body( $r );
     508                $error_position = strpos( $body, $needle );
     509
     510                // Try making request to homepage as well to see if visitors have been whitescreened.
     511                if ( false === $error_position ) {
     512                        $url = home_url( '/' );
     513                        $url = add_query_arg( $scrape_params, $url );
     514                        $r = wp_remote_get( $url, compact( 'cookies', 'headers' ) );
     515                        $body = wp_remote_retrieve_body( $r );
     516                        $error_position = strpos( $body, $needle );
     517                }
     518
     519                delete_transient( $transient );
     520
     521                if ( false !== $error_position ) {
     522                        file_put_contents( $real_file, $previous_content );
     523                        if ( function_exists( 'opcache_invalidate' ) ) {
     524                                opcache_invalidate( $real_file, true );
     525                        }
     526
     527                        $error_output = trim( substr( $body, $error_position + strlen( $needle ) ) );
     528                        $error = json_decode( $error_output, true );
     529                        if ( ! isset( $error['message'] ) ) {
     530                                $message = $error_output;
     531                        } else {
     532                                $message = $error['message'];
     533                                unset( $error['message'] );
     534                        }
     535                        return new WP_Error( 'php_error', $message, $error );
     536                }
     537        }
     538
     539        if ( $theme instanceof WP_Theme ) {
     540                $theme->cache_delete();
     541        }
     542
     543        return true;
     544}
     545
    154546
    155547/**
Note: See TracChangeset for help on using the changeset viewer.