Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.22% covered (warning)
89.22%
91 / 102
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_Email_Editor_Bootstrap
91.92% covered (success)
91.92%
91 / 99
77.78% covered (warning)
77.78%
7 / 9
24.30
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 register_routes
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
3
 permissions_check
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 get_bootstrap_data
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 proxy_to_wpcom
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 drop_unauthorized_post_id
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 save_design
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 respond
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 unexpected_error
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Email editor bootstrap endpoint for the WordPress.com REST API.
4 *
5 * @package automattic/jetpack
6 */
7
8use Automattic\Jetpack\Connection\Manager;
9use Automattic\Jetpack\Connection\Traits\WPCOM_REST_API_Proxy_Request;
10use Automattic\Jetpack\Status\Host;
11
12if ( ! defined( 'ABSPATH' ) ) {
13    exit( 0 );
14}
15
16/**
17 * Class WPCOM_REST_API_V2_Endpoint_Email_Editor_Bootstrap
18 *
19 * Serves the newsletter email design screen its data, and saves the design back.
20 *
21 * The screen ships in this plugin, but the design it edits lives on the WordPress.com shadow blog,
22 * so the record is not in the site's own database on Atomic or self-hosted. Declaring the route here
23 * means the browser calls one local `/wpcom/v2/` URL on all three platforms, and this class decides
24 * whether that is answered in-process (Simple) or proxied (everywhere else).
25 *
26 * Nothing about the email engine lives in this plugin: both callbacks apply a filter and return what
27 * comes back. WordPress.com implements those filters, exactly as it implements
28 * `jetpack_generate_email_preview_html` for the email preview endpoint next door.
29 */
30class WPCOM_REST_API_V2_Endpoint_Email_Editor_Bootstrap extends WP_REST_Controller {
31
32    use WPCOM_REST_API_Proxy_Request;
33
34    /**
35     * Constructor.
36     */
37    public function __construct() {
38        $this->base_api_path                   = 'wpcom';
39        $this->version                         = 'v2';
40        $this->namespace                       = $this->base_api_path . '/' . $this->version;
41        $this->rest_base                       = '/email-editor-bootstrap';
42        $this->wpcom_is_wpcom_only_endpoint    = true;
43        $this->wpcom_is_site_specific_endpoint = true;
44
45        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
46    }
47
48    /**
49     * Registers the routes for the email editor's data layer.
50     *
51     * @see register_rest_route()
52     */
53    public function register_routes() {
54        // Off Simple the record lives on a database this site cannot reach, so every method proxies.
55        // The trait forwards the request's own method, query args and body, so one callback serves
56        // both the read and the write.
57        $is_simple = ( new Host() )->is_wpcom_simple();
58
59        register_rest_route(
60            $this->namespace,
61            $this->rest_base,
62            array(
63                array(
64                    'show_in_index'       => true,
65                    'methods'             => WP_REST_Server::READABLE,
66                    'callback'            => $is_simple
67                        ? array( $this, 'get_bootstrap_data' )
68                        : array( $this, 'proxy_to_wpcom' ),
69                    'permission_callback' => array( $this, 'permissions_check' ),
70                    'args'                => array(
71                        'template_slug' => array(
72                            'description' => __( 'Slug of the email template to open in the editor.', 'jetpack' ),
73                            'type'        => 'string',
74                        ),
75                        'post_id'       => array(
76                            'description' => __( 'Post to preview in the editor canvas. Defaults to the most recent published post.', 'jetpack' ),
77                            'type'        => 'integer',
78                            'minimum'     => 1,
79                        ),
80                    ),
81                ),
82                array(
83                    // The editor sends POST and the server has been observed receiving PUT, so this
84                    // takes the constant covering both rather than betting on either.
85                    'show_in_index'       => true,
86                    'methods'             => WP_REST_Server::EDITABLE,
87                    'callback'            => $is_simple
88                        ? array( $this, 'save_design' )
89                        : array( $this, 'proxy_to_wpcom' ),
90                    'permission_callback' => array( $this, 'permissions_check' ),
91                    'args'                => array(
92                        'design' => array(
93                            'description' => __( 'The email design document to store.', 'jetpack' ),
94                            'type'        => 'object',
95                            'required'    => true,
96                        ),
97                    ),
98                ),
99            )
100        );
101    }
102
103    /**
104     * Checks that the user may read and write the site's email design.
105     *
106     * `edit_theme_options` rather than `edit_posts`: this is the same shape of data core's
107     * `WP_REST_Global_Styles_Controller` guards with that capability, and it is what the design
108     * screen itself requires. It also matters more than a read-only route would — building the
109     * bundle creates a global styles scaffold row on WordPress.com the first time it is called for a
110     * blog, so a low bar here would let anyone make WordPress.com write rows on any blog they can name.
111     *
112     * @return true|WP_Error True if the request may proceed, WP_Error otherwise.
113     */
114    public function permissions_check() {
115        if ( ! ( new Host() )->is_wpcom_simple() && ! ( new Manager() )->is_user_connected() ) {
116            // The proxy refuses an unconnected user too, and returns this same error when it does, so
117            // this changes nothing observable today. It is here because the proxy refuses only while it
118            // is called with no blog-token fallback: without this, a change to that default inside the
119            // connection package would quietly start serving unconnected users on the site's token.
120            return new WP_Error(
121                'rest_unauthorized',
122                __( 'Please connect your user account to WordPress.com', 'jetpack' ),
123                array( 'status' => rest_authorization_required_code() )
124            );
125        }
126
127        if ( ! current_user_can( 'edit_theme_options' ) ) {
128            return new WP_Error(
129                'rest_forbidden',
130                __( 'Sorry, you are not allowed to edit this site&#8217;s email design.', 'jetpack' ),
131                array( 'status' => rest_authorization_required_code() )
132            );
133        }
134
135        return true;
136    }
137
138    /**
139     * Returns the data the email editor needs to start.
140     *
141     * @param WP_REST_Request $request Full data about the request.
142     *
143     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
144     */
145    public function get_bootstrap_data( $request ) {
146        try {
147            /**
148             * Filters the data the newsletter email editor needs to start.
149             *
150             * Returns the editor's settings, its theme, the resolved canvas template, the available
151             * personalization tags and the blog's saved design. Unfiltered on a site with nothing
152             * implementing it, which the endpoint reports as unavailable rather than as an empty design.
153             *
154             * Internal, and not settled: the editor this serves is still being built, so the shape of
155             * what comes back is expected to change. Do not depend on it from outside the plugin.
156             *
157             * @since 16.2
158             * @access private
159             *
160             * @param array|WP_Error|null $data    The editor's bootstrap data. Null until something provides one.
161             * @param WP_REST_Request     $request The REST request.
162             */
163            $data = apply_filters( 'jetpack_email_editor_bootstrap', null, $request );
164        } catch ( Throwable $e ) {
165            return $this->unexpected_error( $e );
166        }
167
168        return $this->respond( $data );
169    }
170
171    /**
172     * Proxies the request, minus a `post_id` the current user may not edit here.
173     *
174     * Off Simple only: WordPress.com checks `edit_post` against the shadow blog rather than against
175     * this site, so the same check has to happen locally too. On Simple its check is this check on
176     * the same blog, which is why those paths have none. The two agree on ids because Jetpack Sync
177     * preserves them, as the email preview endpoint next door already relies on.
178     *
179     * Both methods, though only the read means anything by `post_id` today: the proxy forwards
180     * every query arg whether this route declares it or not, so gating one method would leave the
181     * check somewhere a later reader has to notice.
182     *
183     * @param WP_REST_Request $request Full data about the request.
184     *
185     * @return mixed|WP_Error Response from WordPress.com, or an error.
186     */
187    public function proxy_to_wpcom( $request ) {
188        return $this->proxy_request_to_wpcom_as_user( $this->drop_unauthorized_post_id( $request ) );
189    }
190
191    /**
192     * Drops a `post_id` the current user may not edit from what the proxy will forward.
193     *
194     * Dropped rather than refused, so the screen still opens on the default post the way entering
195     * from Appearance does. WordPress.com falls back the same way for an id it will not use.
196     *
197     * @param WP_REST_Request $request Full data about the request.
198     *
199     * @return WP_REST_Request The request to forward.
200     */
201    private function drop_unauthorized_post_id( $request ) {
202        $post_id = (int) $request->get_param( 'post_id' );
203
204        if ( $post_id <= 0 || current_user_can( 'edit_post', $post_id ) ) {
205            return $request;
206        }
207
208        $query_params = $request->get_query_params();
209
210        unset( $query_params['post_id'] );
211
212        $request->set_query_params( $query_params );
213
214        // Both have to go: the proxy forwards the body verbatim beside the query args, and on a
215        // GET a JSON body outranks the query string in `get_param()` -- so an id moved there would
216        // be read by the check above and then sent anyway.
217        //
218        // Decoded as objects, not arrays: a design document's empty objects come back from an
219        // associative decode as empty arrays, and re-encoding would send `[]` where it had `{}`.
220        $body = json_decode( (string) $request->get_body() );
221
222        if ( $body instanceof stdClass && property_exists( $body, 'post_id' ) ) {
223            unset( $body->post_id );
224
225            $request->set_body( wp_json_encode( $body, JSON_UNESCAPED_SLASHES ) );
226        }
227
228        return $request;
229    }
230
231    /**
232     * Saves the site's email design.
233     *
234     * @param WP_REST_Request $request Full data about the request.
235     *
236     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
237     */
238    public function save_design( $request ) {
239        try {
240            /**
241             * Filters the result of saving the newsletter email design.
242             *
243             * The editor sends the whole styles document rather than a patch, so implementations
244             * replace rather than merge. Implementations should report on a read-back of the stored
245             * design rather than echoing the submitted one: sanitizing drops properties outside the
246             * theme.json schema, so a save can succeed into invisibility, and the screen has to be
247             * able to tell a person their edit did not survive.
248             *
249             * Internal, and not settled, for the same reason as the read above: expect the shape of the
250             * design document and of what is returned to change while the editor is being built.
251             *
252             * @since 16.2
253             * @access private
254             *
255             * @param array|WP_Error|null $result  The stored design. Null until something provides one.
256             * @param WP_REST_Request     $request The REST request.
257             */
258            $result = apply_filters( 'jetpack_email_editor_save_design', null, $request );
259        } catch ( Throwable $e ) {
260            return $this->unexpected_error( $e );
261        }
262
263        return $this->respond( $result );
264    }
265
266    /**
267     * Turns a filtered value into a response.
268     *
269     * Anything that is not an array or a `WP_Error` is treated as no answer at all, because this data
270     * layer exists to stop a design silently reading or saving as nothing. An unfiltered `null` means
271     * nothing implements the filter on this site — the plugin is running somewhere its WordPress.com
272     * half has not shipped. `false` means an implementation failed and said so the way PHP usually
273     * does. Returning either bare would be indistinguishable from a site whose email design is
274     * genuinely empty, so both report as unavailable instead.
275     *
276     * @param array|WP_Error|null $value The filtered value.
277     *
278     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
279     */
280    private function respond( $value ) {
281        if ( is_wp_error( $value ) ) {
282            return $value;
283        }
284
285        if ( ! is_array( $value ) ) {
286            return new WP_Error(
287                'email_editor_unavailable',
288                __( 'The email editor is not available on this site.', 'jetpack' ),
289                array( 'status' => 501 )
290            );
291        }
292
293        return rest_ensure_response( $value );
294    }
295
296    /**
297     * Turns a throwing filter into an error response.
298     *
299     * On Simple the filter runs in this process, so an implementation that raises would otherwise
300     * surface as a bare fatal with nothing for the screen to show. Off Simple the request is proxied
301     * and WordPress.com catches its own.
302     *
303     * @param Throwable $e The exception raised while filtering.
304     *
305     * @return WP_Error
306     */
307    private function unexpected_error( $e ) {
308        /**
309         * Fires when a filter serving the email editor raises.
310         *
311         * This plugin does not record the exception itself — the message can carry internals, so where
312         * it is safe to write it is the host's call rather than ours. Hook this to log it.
313         *
314         * @since 16.2
315         *
316         * @param Throwable $e The exception raised while filtering.
317         */
318        do_action( 'jetpack_email_editor_error', $e );
319
320        $data = array( 'status' => 500 );
321
322        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
323            // Without this the raise is invisible: the message is the only thing separating a bug in
324            // the implementation from an outage. Debug builds only, since it can carry internals.
325            $data['exception'] = $e->getMessage();
326        }
327
328        return new WP_Error(
329            'email_editor_failed',
330            __( 'The email editor could not be reached. Please try again.', 'jetpack' ),
331            $data
332        );
333    }
334}
335
336wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Email_Editor_Bootstrap' );