Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.24% covered (warning)
88.24%
75 / 85
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_Email_Editor_Bootstrap
91.46% covered (success)
91.46%
75 / 82
85.71% covered (warning)
85.71%
6 / 7
18.20
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%
36 / 36
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
 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_request_to_wpcom_as_user' ),
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                    ),
76                ),
77                array(
78                    // The editor sends POST and the server has been observed receiving PUT, so this
79                    // takes the constant covering both rather than betting on either.
80                    'show_in_index'       => true,
81                    'methods'             => WP_REST_Server::EDITABLE,
82                    'callback'            => $is_simple
83                        ? array( $this, 'save_design' )
84                        : array( $this, 'proxy_request_to_wpcom_as_user' ),
85                    'permission_callback' => array( $this, 'permissions_check' ),
86                    'args'                => array(
87                        'design' => array(
88                            'description' => __( 'The email design document to store.', 'jetpack' ),
89                            'type'        => 'object',
90                            'required'    => true,
91                        ),
92                    ),
93                ),
94            )
95        );
96    }
97
98    /**
99     * Checks that the user may read and write the site's email design.
100     *
101     * `edit_theme_options` rather than `edit_posts`: this is the same shape of data core's
102     * `WP_REST_Global_Styles_Controller` guards with that capability, and it is what the design
103     * screen itself requires. It also matters more than a read-only route would — building the
104     * bundle creates a global styles scaffold row on WordPress.com the first time it is called for a
105     * blog, so a low bar here would let anyone make WordPress.com write rows on any blog they can name.
106     *
107     * @return true|WP_Error True if the request may proceed, WP_Error otherwise.
108     */
109    public function permissions_check() {
110        if ( ! ( new Host() )->is_wpcom_simple() && ! ( new Manager() )->is_user_connected() ) {
111            // The proxy refuses an unconnected user too, and returns this same error when it does, so
112            // this changes nothing observable today. It is here because the proxy refuses only while it
113            // is called with no blog-token fallback: without this, a change to that default inside the
114            // connection package would quietly start serving unconnected users on the site's token.
115            return new WP_Error(
116                'rest_unauthorized',
117                __( 'Please connect your user account to WordPress.com', 'jetpack' ),
118                array( 'status' => rest_authorization_required_code() )
119            );
120        }
121
122        if ( ! current_user_can( 'edit_theme_options' ) ) {
123            return new WP_Error(
124                'rest_forbidden',
125                __( 'Sorry, you are not allowed to edit this site&#8217;s email design.', 'jetpack' ),
126                array( 'status' => rest_authorization_required_code() )
127            );
128        }
129
130        return true;
131    }
132
133    /**
134     * Returns the data the email editor needs to start.
135     *
136     * @param WP_REST_Request $request Full data about the request.
137     *
138     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
139     */
140    public function get_bootstrap_data( $request ) {
141        try {
142            /**
143             * Filters the data the newsletter email editor needs to start.
144             *
145             * Returns the editor's settings, its theme, the resolved canvas template, the available
146             * personalization tags and the blog's saved design. Unfiltered on a site with nothing
147             * implementing it, which the endpoint reports as unavailable rather than as an empty design.
148             *
149             * Internal, and not settled: the editor this serves is still being built, so the shape of
150             * what comes back is expected to change. Do not depend on it from outside the plugin.
151             *
152             * @since 16.2
153             * @access private
154             *
155             * @param array|WP_Error|null $data    The editor's bootstrap data. Null until something provides one.
156             * @param WP_REST_Request     $request The REST request.
157             */
158            $data = apply_filters( 'jetpack_email_editor_bootstrap', null, $request );
159        } catch ( Throwable $e ) {
160            return $this->unexpected_error( $e );
161        }
162
163        return $this->respond( $data );
164    }
165
166    /**
167     * Saves the site's email design.
168     *
169     * @param WP_REST_Request $request Full data about the request.
170     *
171     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
172     */
173    public function save_design( $request ) {
174        try {
175            /**
176             * Filters the result of saving the newsletter email design.
177             *
178             * The editor sends the whole styles document rather than a patch, so implementations
179             * replace rather than merge. Implementations should report on a read-back of the stored
180             * design rather than echoing the submitted one: sanitizing drops properties outside the
181             * theme.json schema, so a save can succeed into invisibility, and the screen has to be
182             * able to tell a person their edit did not survive.
183             *
184             * Internal, and not settled, for the same reason as the read above: expect the shape of the
185             * design document and of what is returned to change while the editor is being built.
186             *
187             * @since 16.2
188             * @access private
189             *
190             * @param array|WP_Error|null $result  The stored design. Null until something provides one.
191             * @param WP_REST_Request     $request The REST request.
192             */
193            $result = apply_filters( 'jetpack_email_editor_save_design', null, $request );
194        } catch ( Throwable $e ) {
195            return $this->unexpected_error( $e );
196        }
197
198        return $this->respond( $result );
199    }
200
201    /**
202     * Turns a filtered value into a response.
203     *
204     * Anything that is not an array or a `WP_Error` is treated as no answer at all, because this data
205     * layer exists to stop a design silently reading or saving as nothing. An unfiltered `null` means
206     * nothing implements the filter on this site — the plugin is running somewhere its WordPress.com
207     * half has not shipped. `false` means an implementation failed and said so the way PHP usually
208     * does. Returning either bare would be indistinguishable from a site whose email design is
209     * genuinely empty, so both report as unavailable instead.
210     *
211     * @param array|WP_Error|null $value The filtered value.
212     *
213     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
214     */
215    private function respond( $value ) {
216        if ( is_wp_error( $value ) ) {
217            return $value;
218        }
219
220        if ( ! is_array( $value ) ) {
221            return new WP_Error(
222                'email_editor_unavailable',
223                __( 'The email editor is not available on this site.', 'jetpack' ),
224                array( 'status' => 501 )
225            );
226        }
227
228        return rest_ensure_response( $value );
229    }
230
231    /**
232     * Turns a throwing filter into an error response.
233     *
234     * On Simple the filter runs in this process, so an implementation that raises would otherwise
235     * surface as a bare fatal with nothing for the screen to show. Off Simple the request is proxied
236     * and WordPress.com catches its own.
237     *
238     * @param Throwable $e The exception raised while filtering.
239     *
240     * @return WP_Error
241     */
242    private function unexpected_error( $e ) {
243        /**
244         * Fires when a filter serving the email editor raises.
245         *
246         * This plugin does not record the exception itself — the message can carry internals, so where
247         * it is safe to write it is the host's call rather than ours. Hook this to log it.
248         *
249         * @since 16.2
250         *
251         * @param Throwable $e The exception raised while filtering.
252         */
253        do_action( 'jetpack_email_editor_error', $e );
254
255        $data = array( 'status' => 500 );
256
257        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
258            // Without this the raise is invisible: the message is the only thing separating a bug in
259            // the implementation from an outage. Debug builds only, since it can carry internals.
260            $data['exception'] = $e->getMessage();
261        }
262
263        return new WP_Error(
264            'email_editor_failed',
265            __( 'The email editor could not be reached. Please try again.', 'jetpack' ),
266            $data
267        );
268    }
269}
270
271wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Email_Editor_Bootstrap' );