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
102 changes: 78 additions & 24 deletions inc/class-shortcode-ui.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,34 @@ 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' ) );
Copy link
Contributor

Choose a reason for hiding this comment

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

We can remove this registration, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh yep

add_action( 'rest_api_init', array( $this, 'action_rest_api_init' ) );
add_filter( 'wp_editor_settings', array( $this, 'filter_wp_editor_settings' ), 10, 2 );
}

public function action_rest_api_init() {

register_rest_route( 'shortcode-ui/v1', 'preview', array(
'methods' => 'GET',
'callback' => array( $this, 'handle_shortcode_preview' ),
'args' => array(
'query' => array(
'sanitize_callback' => array( $this, 'sanitize_rest_arg_arg_query' ),
),
)
) );

register_rest_route( 'shortcode-ui/v1', 'preview/bulk', array(
'methods' => 'GET',
'callback' => array( $this, 'handle_shortcode_preview_bulk' ),
'args' => array(
'queries' => array(
'sanitize_callback' => array( $this, 'sanitize_rest_arg_arg_queries' ),
),
),
) );

}

/**
* When a WP_Editor is initialized on a page, call the 'register_shortcode_ui' action.
*
Expand Down Expand Up @@ -244,9 +269,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 @@ -336,7 +365,7 @@ private function render_shortcode_for_preview( $shortcode, $post_id = null ) {
}

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

if ( ! empty( $post_id ) ) {
Expand Down Expand Up @@ -366,38 +395,63 @@ private function render_shortcode_for_preview( $shortcode, $post_id = null ) {
}

/**
* Get a bunch of shortcodes to render in MCE preview.
* Get a preview for a single shortcode to render in MCE preview.
*/
public function handle_ajax_bulk_do_shortcode() {

if ( is_array( $_POST['queries'] ) ) {
public function handle_shortcode_preview( WP_REST_Request $request ) {
return $this->get_shortcode_preview( $request->get_param('query') );
}

$responses = array();
/**
* Get a bunch of shortcodes to render in MCE preview.
*/
public function handle_shortcode_preview_bulk( WP_REST_Request $request ) {

foreach ( $_POST['queries'] as $posted_query ) {

// 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;
}
$queries = $request->get_param('queries');
$responses = array();

if ( is_array( $queries ) ) {
foreach ( $queries as $posted_query ) {
$responses[ $posted_query['counter'] ] = array(
'query' => $posted_query,
'response' => $this->render_shortcode_for_preview( $shortcode, $post_id ),
'query' => $posted_query,
'response' => $this->render_shortcode_for_preview( $posted_query['shortcode'], $posted_query['post_id'] ),
);
}
}

return array_filter( $responses );

}

public function sanitize_rest_arg_arg_query( $dirty_args ) {

$clean_args = array(
'shortcode' => null,
'post_id' => null,
);

// Don't sanitize shortcodes — can contain HTML kses doesn't allow (e.g. sourcecode shortcode)
if ( ! empty( $dirty_args['shortcode'] ) ) {
$clean_args['shortcode'] = stripslashes( $dirty_args['shortcode'] );
}

if ( isset( $dirty_args['post_id'] ) ) {
$clean_args['post_id'] = intval( $dirty_args['post_id'] );
}

if ( isset( $dirty_args['nonce'] ) ) {
$clean_args['nonce'] = sanitize_text_field( $dirty_args['nonce'] );
}

wp_send_json_success( $responses );
exit;
if ( isset( $dirty_args['counter'] ) ) {
$clean_args['counter'] = intval( $dirty_args['counter'] );
}

return $clean_args;

}

public function sanitize_rest_arg_arg_queries( $arg ) {
return array_map( array( $this, 'sanitize_rest_arg_arg_query' ), (array) $arg );
}

/**
Expand Down
105 changes: 9 additions & 96 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 @@ -792,13 +733,16 @@ var Fetcher = (function() {
return;
}

var request = $.post( ajaxurl + '?action=bulk_do_shortcode', {
var request = $.get( shortcodeUIData.urls.bulkPreview, {
_wpnonce: shortcodeUIData.nonces.wp_rest,
queries: _.pluck( fetcher.queries, 'query' )
}
);

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

_.each( responses, function( result, index ) {

var matchedQuery = _.findWhere( fetcher.queries, {
counter: parseInt( index ),
});
Expand All @@ -807,7 +751,9 @@ var Fetcher = (function() {
fetcher.queries = _.without( fetcher.queries, matchedQuery );
matchedQuery.promise.resolve( result );
}

} );

} );
};

Expand Down Expand Up @@ -942,44 +888,11 @@ var shortcodeViewConstructor = {
*/
delayedFetch: function() {
return fetcher.queueToFetch({
post_id: $( '#post_ID' ).val(),
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 );
} );
}
},

/**
* Get the shortcode model and open modal UI for editing.
*
Expand Down
59 changes: 0 additions & 59 deletions js-tests/src/utils/mceViewConstructorSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,65 +73,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
Loading