Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use WP Rest API infrastructure for previews #583

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
169 changes: 140 additions & 29 deletions inc/class-shortcode-ui.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ private function __construct() {
private function setup_actions() {
add_action( 'admin_enqueue_scripts', array( $this, 'action_admin_enqueue_scripts' ) );
add_action( 'wp_enqueue_editor', array( $this, 'action_wp_enqueue_editor' ) );
add_action( 'wp_ajax_bulk_do_shortcode', array( $this, 'handle_ajax_bulk_do_shortcode' ) );
add_action( 'rest_api_init', array( $this, 'action_rest_api_init' ) );
add_filter( 'wp_editor_settings', array( $this, 'filter_wp_editor_settings' ), 10, 2 );
}

Expand Down Expand Up @@ -247,9 +247,13 @@ public function enqueue() {
'insert_content_label' => __( 'Insert Content', 'shortcode-ui' ),
),
'nonces' => array(
'preview' => wp_create_nonce( 'shortcode-ui-preview' ),
'wp_rest' => wp_create_nonce( 'wp_rest' ),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless I'm missing something, we're not checking nonces on any of the REST request handlers. Should we be?

I think there might be a benefit to making this unauthenticated, as then it would be easier to reuse the functionality in external editors and the like. However, that requires a much more solid approach to security, as it opens an attack surface for anyone to compel a server to render an arbitrary shortcode. Given that people still use things like [php] shortcodes, this gets sketchy pretty fast.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nonce check is done by the rest API itself. Otherwise the request is treated as unauthenticated - which we are checking for when we do the edit_post caps check.

Interesting thoughts RE opening this up for others. However I think we should keep it authenticated for now. There would be quite a big potential for exploiting this.

@danielbachhuber can you flag and endpoint as requiring auth? Perhaps in the schema?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we need to make sure the endpoint implements authentication. We'll want to include a permissions_callback for each endpoint — and it would be good to write tests around this as well.

'thumbnailImage' => wp_create_nonce( 'shortcode-ui-get-thumbnail-image' ),
),
'urls' => array(
'preview' => rest_url( '/shortcode-ui/v1/preview' ),
'bulkPreview' => rest_url( '/shortcode-ui/v1/preview/bulk' ),
),
) );

// add templates to the footer, instead of where we're at now
Expand Down Expand Up @@ -338,10 +342,6 @@ private function render_shortcode_for_preview( $shortcode, $post_id = null ) {
define( 'SHORTCODE_UI_DOING_PREVIEW', true );
}

if ( ! current_user_can( 'edit_post', $post_id ) ) {
return esc_html__( "Something's rotten in the state of Denmark", 'shortcode-ui' );
}

if ( ! empty( $post_id ) ) {
// @codingStandardsIgnoreStart
global $post;
Expand Down Expand Up @@ -369,37 +369,148 @@ private function render_shortcode_for_preview( $shortcode, $post_id = null ) {
}

/**
* Get a bunch of shortcodes to render in MCE preview.
* Register rest api endpoints.
*/
public function handle_ajax_bulk_do_shortcode() {
public function action_rest_api_init() {

register_rest_route( 'shortcode-ui/v1', 'preview', array(
'methods' => 'GET',
'callback' => array( $this, 'rest_preview_callback' ),
'permission_callback' => array( $this, 'rest_preview_permission_callback' ),
'args' => array(
'shortcode' => array(
'sanitize_callback' => array( $this, 'rest_sanitize_shortcode' ),
'required' => true,
),
'post_id' => array(
'sanitize_callback' => array( $this, 'rest_sanitize_post_id' ),
'required' => true,
),
),
) );

if ( is_array( $_POST['queries'] ) ) {
register_rest_route( 'shortcode-ui/v1', 'preview/bulk', array(
'methods' => 'GET',
'callback' => array( $this, 'rest_preview_bulk_callback' ),
'permission_callback' => array( $this, 'rest_preview_permission_callback' ),
'args' => array(
'queries' => array(
'sanitize_callback' => array( $this, 'rest_sanitize_queries' ),
'validate_callback' => array( $this, 'rest_validate_queries' ),
),
'post_id' => array(
'sanitize_callback' => array( $this, 'rest_sanitize_post_id' ),
),
),
) );

$responses = array();
}

foreach ( $_POST['queries'] as $posted_query ) {
/**
* Permission check for getting a shortcode preview.
*
* @param WP_REST_Request $request
* @return boolean
*/
public function rest_preview_permission_callback( WP_REST_Request $request ) {

// Don't sanitize shortcodes — can contain HTML kses doesn't allow (e.g. sourcecode shortcode)
if ( ! empty( $posted_query['shortcode'] ) ) {
$shortcode = stripslashes( $posted_query['shortcode'] );
} else {
$shortcode = null;
}
if ( isset( $posted_query['post_id'] ) ) {
$post_id = intval( $posted_query['post_id'] );
} else {
$post_id = null;
}
$post_id = $request->get_param( 'post_id' );

$responses[ $posted_query['counter'] ] = array(
'query' => $posted_query,
'response' => $this->render_shortcode_for_preview( $shortcode, $post_id ),
);
}
if ( empty( $post_id ) ) {
return new WP_Error( 'rest_no_post_id', __( 'No Post ID.' ) );
}

if ( ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error( 'rest_no_edit_post_cap', __( 'You do not have permission to edit this Post.' ) );
}

wp_send_json_success( $responses );
exit;
return true;
}

/**
* Sanitize collection of shortcode queries.
*
* Used for bulk requests.
*
* @param array $queries Queries
* @return string Queries
*/
public function rest_sanitize_queries( $queries ) {
$clean_queries = array();
foreach ( $queries as $query ) {
$clean_queries[] = array(
'counter' => absint( $query['counter'] ),
'shortcode' => $this->rest_sanitize_shortcode( $query['shortcode'] ),
);
}
return $clean_queries;
}

/**
* Validate collection of shortcodes.
*
* Used for bulk requests.
*
* @param array $queries Queries
* @return boolean
*/
public function rest_validate_queries( $shortcodes ) {
return is_array( $shortcodes );
}

/**
* Sanitize rest request shortcode arg.
*
* @param string $shortcode Shortcode
* @return string Shortcode
*/
public function rest_sanitize_shortcode( $shortcode ) {
return stripslashes( $shortcode );
}

/**
* Sanitize Post ID.
*
* @param mixed $shortcode Post Id
* @return int Post Id
*/
public function rest_sanitize_post_id( $post_id ) {
return absint( $post_id );
}

/**
* Get a preview for a single shortcode to render in MCE preview.
*/
public function rest_preview_callback( WP_REST_Request $request ) {

$shortcode = $request->get_param( 'shortcode' );
$post_id = $request->get_param( 'post_id' );

return array(
'shortcode' => $shortcode,
'post_id' => $post_id,
'preview' => $this->render_shortcode_for_preview( $shortcode, $post_id ),
);
}

/**
* Get a bunch of shortcodes previews to render in MCE preview.
*/
public function rest_preview_bulk_callback( WP_REST_Request $request ) {

$previews = array();
$post_id = $request->get_param( 'post_id' );

foreach ( $request->get_param( 'queries' ) as $query ) {
$previews[] = array(
'shortcode' => $query['shortcode'],
'post_id' => $post_id,
'counter' => $query['counter'],
'preview' => $this->render_shortcode_for_preview( $query['shortcode'], $post_id ),
);
}

return array_filter( $previews );

}

Expand Down
129 changes: 20 additions & 109 deletions js-tests/build/specs.js
Original file line number Diff line number Diff line change
Expand Up @@ -382,65 +382,6 @@ describe( "MCE View Constructor", function() {

} );

describe( "Fetch preview HTML", function() {

beforeEach(function() {
jasmine.Ajax.install();
});

afterEach(function() {
jasmine.Ajax.uninstall();
});

var constructor = jQuery.extend( true, {
render: function( force ) {},
}, MceViewConstructor );

// Mock shortcode model data.
constructor.shortcodeModel = jQuery.extend( true, {}, sui.shortcodes.first() );

it( 'Fetches data success', function(){

spyOn( wp.ajax, "post" ).and.callThrough();
spyOn( constructor, "render" );

constructor.fetch();

expect( constructor.fetching ).toEqual( true );
expect( constructor.content ).toEqual( undefined );
expect( wp.ajax.post ).toHaveBeenCalled();
expect( constructor.render ).not.toHaveBeenCalled();

jasmine.Ajax.requests.mostRecent().respondWith( {
'status': 200,
'responseText': '{"success":true,"data":"test preview response body"}'
} );

expect( constructor.fetching ).toEqual( undefined );
expect( constructor.content ).toEqual( 'test preview response body' );
expect( constructor.render ).toHaveBeenCalled();

});

it( 'Handles errors when fetching data', function() {

spyOn( constructor, "render" );

constructor.fetch();

jasmine.Ajax.requests.mostRecent().respondWith( {
'status': 500,
'responseText': '{"success":false}'
});

expect( constructor.fetching ).toEqual( undefined );
expect( constructor.content ).toContain( 'shortcake-error' );
expect( constructor.render ).toHaveBeenCalled();

} );

} );

it( 'parses simple shortcode', function() {
var shortcode = MceViewConstructor.parseShortcodeString( '[test_shortcode attr="test value"]');
expect( shortcode instanceof Shortcode ).toEqual( true );
Expand Down Expand Up @@ -761,15 +702,13 @@ var Fetcher = (function() {
* }
* @return {Deferred}
*/
this.queueToFetch = function( query ) {
this.queueToFetch = function( shortcode ) {
var fetchPromise = new $.Deferred();

query.counter = ++fetcher.counter;

fetcher.queries.push({
promise: fetchPromise,
query: query,
counter: query.counter
shortcode: shortcode,
counter: ++fetcher.counter
});

if ( ! fetcher.timeout ) {
Expand All @@ -795,22 +734,30 @@ var Fetcher = (function() {
return;
}

var request = $.post( ajaxurl + '?action=bulk_do_shortcode', {
queries: _.pluck( fetcher.queries, 'query' )
var request = $.get( shortcodeUIData.urls.bulkPreview, {
_wpnonce: shortcodeUIData.nonces.wp_rest,
post_id: $( '#post_ID' ).val(),
queries: _.map( fetcher.queries, function( query ) {
return { shortcode: query.shortcode, counter: query.counter };
} )
}
);

request.done( function( response ) {
_.each( response.data, function( result, index ) {
request.done( function( responses ) {

_.each( responses, function( result ) {

var matchedQuery = _.findWhere( fetcher.queries, {
counter: parseInt( index ),
counter: result.counter,
});

if ( matchedQuery ) {
fetcher.queries = _.without( fetcher.queries, matchedQuery );
matchedQuery.promise.resolve( result );
matchedQuery.promise.resolve( result.preview );
}

} );

} );
};

Expand Down Expand Up @@ -857,8 +804,8 @@ var shortcodeViewConstructor = {
this.shortcodeModel = this.getShortcodeModel( this.shortcode );
this.fetching = this.delayedFetch();

this.fetching.done( function( queryResponse ) {
var response = queryResponse.response;
this.fetching.done( function( response ) {

if ( '' === response ) {
var span = $('<span />').addClass('shortcake-notice shortcake-empty').text( self.shortcodeModel.formatShortcode() );
var wrapper = $('<div />').html( span );
Expand Down Expand Up @@ -944,43 +891,7 @@ var shortcodeViewConstructor = {
* @return {Promise}
*/
delayedFetch: function() {
return fetcher.queueToFetch({
post_id: $( '#post_ID' ).val(),
shortcode: this.shortcodeModel.formatShortcode(),
nonce: shortcodeUIData.nonces.preview,
});
},

/**
* Fetch a preview of a single shortcode.
*
* Async. Sets this.content and calls this.render.
*
* @return undefined
*/
fetch: function() {
var self = this;

if ( ! this.fetching ) {
this.fetching = true;

wp.ajax.post( 'do_shortcode', {
post_id: $( '#post_ID' ).val(),
shortcode: this.shortcodeModel.formatShortcode(),
nonce: shortcodeUIData.nonces.preview,
}).done( function( response ) {
if ( '' === response ) {
self.content = '<span class="shortcake-notice shortcake-empty">' + self.shortcodeModel.formatShortcode() + '</span>';
} else {
self.content = response;
}
}).fail( function() {
self.content = '<span class="shortcake-error">' + shortcodeUIData.strings.mce_view_error + '</span>';
} ).always( function() {
delete self.fetching;
self.render( null, true );
} );
}
return fetcher.queueToFetch( this.shortcodeModel.formatShortcode() );
},

/**
Expand Down
Loading