Make WordPress Core


Ignore:
Timestamp:
10/04/2017 12:19:16 AM (7 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.