Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.66% covered (success)
98.66%
147 / 149
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
WPCOM_REST_API_V2_Endpoint_VideoPress_Edits
100.00% covered (success)
100.00%
145 / 145
100.00% covered (success)
100.00%
9 / 9
27
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
93 / 93
100.00% covered (success)
100.00%
1 / 1
4
 permissions_check
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 get_edits
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 save_edits
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 retry_edits
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 restore_original
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_storyboard
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 proxy_request
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
14
1<?php
2/**
3 * REST endpoints for trimming and cutting VideoPress videos.
4 *
5 * @package automattic/jetpack-videopress
6 */
7
8namespace Automattic\Jetpack\VideoPress;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Constants;
12use WP_Error;
13use WP_REST_Request;
14use WP_REST_Response;
15use WP_REST_Server;
16
17if ( ! defined( 'ABSPATH' ) ) {
18    exit( 0 );
19}
20
21/**
22 * Proxies edit operations and original-timeline storyboards to WordPress.com.
23 *
24 * @phan-constructor-used-for-side-effects
25 */
26class WPCOM_REST_API_V2_Endpoint_VideoPress_Edits {
27
28    /**
29     * Constructor.
30     */
31    public function __construct() {
32        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
33    }
34
35    /**
36     * Register the feature-gated editor routes.
37     */
38    public function register_routes() {
39        // WordPress.com checks the rollout on the target site in the upstream endpoint.
40        if ( ! ( defined( 'IS_WPCOM' ) && IS_WPCOM ) && ! Admin_UI::is_trim_cut_enabled() ) {
41            return;
42        }
43
44        $guid_arg = array(
45            'guid' => array(
46                'description' => __( 'The VideoPress GUID.', 'jetpack-videopress-pkg' ),
47                'type'        => 'string',
48                'required'    => true,
49            ),
50        );
51
52        $edit_args = array(
53            'base_revision' => array(
54                'description' => __( 'The revision the edits are based on.', 'jetpack-videopress-pkg' ),
55                'type'        => 'integer',
56                'minimum'     => 0,
57                'required'    => true,
58            ),
59            'operations'    => array(
60                'description' => __( 'Trim and cut operations on the original video timeline.', 'jetpack-videopress-pkg' ),
61                'type'        => 'array',
62                'required'    => true,
63                'items'       => array(
64                    'type'                 => 'object',
65                    'additionalProperties' => false,
66                    'required'             => array( 'type', 'start_ms', 'end_ms' ),
67                    'properties'           => array(
68                        'type'     => array(
69                            'type' => 'string',
70                            'enum' => array( 'trim', 'cut' ),
71                        ),
72                        'start_ms' => array(
73                            'type'    => 'integer',
74                            'minimum' => 0,
75                        ),
76                        'end_ms'   => array(
77                            'type'    => 'integer',
78                            'minimum' => 1,
79                        ),
80                    ),
81                ),
82            ),
83        );
84
85        register_rest_route(
86            'wpcom/v2',
87            'videopress/(?P<guid>[A-Za-z0-9]{8})/edits',
88            array(
89                'args' => $guid_arg,
90                0      => array(
91                    'methods'             => WP_REST_Server::READABLE,
92                    'callback'            => array( $this, 'get_edits' ),
93                    'permission_callback' => array( $this, 'permissions_check' ),
94                ),
95                1      => array(
96                    'methods'             => WP_REST_Server::CREATABLE,
97                    'callback'            => array( $this, 'save_edits' ),
98                    'permission_callback' => array( $this, 'permissions_check' ),
99                    'args'                => $edit_args,
100                ),
101                2      => array(
102                    'methods'             => WP_REST_Server::DELETABLE,
103                    'callback'            => array( $this, 'restore_original' ),
104                    'permission_callback' => array( $this, 'permissions_check' ),
105                ),
106            )
107        );
108
109        register_rest_route(
110            'wpcom/v2',
111            'videopress/(?P<guid>[A-Za-z0-9]{8})/storyboard',
112            array(
113                'args'                => $guid_arg,
114                'methods'             => WP_REST_Server::READABLE,
115                'callback'            => array( $this, 'get_storyboard' ),
116                'permission_callback' => array( $this, 'permissions_check' ),
117            )
118        );
119        register_rest_route(
120            'wpcom/v2',
121            'videopress/(?P<guid>[A-Za-z0-9]{8})/edits/retry',
122            array(
123                'args'                => array_merge(
124                    $guid_arg,
125                    array(
126                        'job_id' => array(
127                            'type'     => 'string',
128                            'pattern'  => '^[0-9]+$',
129                            'required' => true,
130                        ),
131                    )
132                ),
133                'methods'             => WP_REST_Server::CREATABLE,
134                'callback'            => array( $this, 'retry_edits' ),
135                'permission_callback' => array( $this, 'permissions_check' ),
136            )
137        );
138    }
139
140    /**
141     * Authorize access to the original video and editing controls.
142     *
143     * @param WP_REST_Request $request The request object.
144     * @return bool
145     */
146    public function permissions_check( $request ) {
147        return Data::can_perform_action()
148            && current_user_can( 'upload_files' )
149            && current_user_can( 'edit_post', WPCOM_REST_API_V2_Endpoint_VideoPress::get_video_attachment_id( $request['guid'] ) );
150    }
151
152    /**
153     * Fetch the current revision and processing job.
154     *
155     * @param WP_REST_Request $request The request object.
156     * @return WP_REST_Response|WP_Error
157     */
158    public function get_edits( $request ) {
159        return $this->proxy_request( sprintf( 'videos/%s/edits', $request['guid'] ) );
160    }
161
162    /**
163     * Submit operations against a specific revision.
164     *
165     * @param WP_REST_Request $request The request object.
166     * @return WP_REST_Response|WP_Error
167     */
168    public function save_edits( $request ) {
169        return $this->proxy_request(
170            sprintf( 'videos/%s/edits', $request['guid'] ),
171            'POST',
172            array(
173                'base_revision' => $request['base_revision'],
174                'operations'    => $request['operations'],
175            )
176        );
177    }
178
179    /**
180     * Retry the exact failed job without changing the video's identity.
181     *
182     * @param WP_REST_Request $request The request object.
183     * @return WP_REST_Response|WP_Error
184     */
185    public function retry_edits( $request ) {
186        return $this->proxy_request(
187            sprintf( 'videos/%s/edits/retry', $request['guid'] ),
188            'POST',
189            array( 'job_id' => $request['job_id'] )
190        );
191    }
192
193    /**
194     * Restore the original using the upstream API's POST deletion convention.
195     *
196     * @param WP_REST_Request $request The request object.
197     * @return WP_REST_Response|WP_Error
198     */
199    public function restore_original( $request ) {
200        return $this->proxy_request( sprintf( 'videos/%s/edits/delete', $request['guid'] ), 'POST' );
201    }
202
203    /**
204     * Fetch the original video's storyboard tiles.
205     *
206     * @param WP_REST_Request $request The request object.
207     * @return WP_REST_Response|WP_Error
208     */
209    public function get_storyboard( $request ) {
210        return $this->proxy_request( sprintf( 'videos/%s/storyboard', $request['guid'] ) );
211    }
212
213    /**
214     * Preserve upstream statuses and normalize v1.1 errors for REST clients.
215     *
216     * @param string     $path   WordPress.com REST v1.1 path.
217     * @param string     $method HTTP method.
218     * @param array|null $body   JSON request data.
219     * @return WP_REST_Response|WP_Error
220     */
221    private function proxy_request( $path, $method = 'GET', $body = null ) {
222        $args = array( 'method' => $method );
223        if ( null !== $body ) {
224            $args['headers'] = array( 'content-type' => 'application/json' );
225            $body            = wp_json_encode( $body, JSON_UNESCAPED_SLASHES );
226        }
227
228        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
229            try {
230                $token = VideoPressToken::videopress_onetime_upload_token();
231            } catch ( Upload_Exception $e ) {
232                return new WP_Error( 'videopress_edits_request_failed', $e->getMessage(), array( 'status' => 502 ) );
233            }
234            $args['headers']['Authorization'] = sprintf( 'X_UPLOAD_TOKEN token="%s" blog_id="%s"', $token, VideoPressToken::blog_id() );
235            $args['body']                     = $body;
236            $args['timeout']                  = 30;
237            $args['redirection']              = 0;
238            $url                              = Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' ) . '/rest/v1.1/' . $path;
239            // @phan-suppress-next-line PhanAccessMethodInternal -- Use the poster transport; the direct client only dispatches v2 routes.
240            $response = Client::_wp_remote_request( $url, $args );
241        } else {
242            $response = Client::wpcom_json_api_request_as_blog( $path, '1.1', $args, $body, 'rest' );
243        }
244        if ( is_wp_error( $response ) ) {
245            return new WP_Error( 'videopress_edits_request_failed', $response->get_error_message(), array( 'status' => 502 ) );
246        }
247
248        $status  = (int) wp_remote_retrieve_response_code( $response );
249        $decoded = json_decode( wp_remote_retrieve_body( $response ), true );
250        if ( ! is_array( $decoded ) || $status < 100 ) {
251            return new WP_Error(
252                'videopress_edits_invalid_response',
253                __( 'The video editing service returned an invalid response.', 'jetpack-videopress-pkg' ),
254                array( 'status' => 502 )
255            );
256        }
257
258        if ( $status >= 400 && isset( $decoded['error'] ) && ! isset( $decoded['code'] ) ) {
259            $decoded['code'] = $decoded['error'];
260            unset( $decoded['error'] );
261        }
262        if ( $status >= 400 ) {
263            $decoded['data']           = isset( $decoded['data'] ) && is_array( $decoded['data'] ) ? $decoded['data'] : array();
264            $decoded['data']['status'] = $status;
265        }
266
267        return new WP_REST_Response( $decoded, $status );
268    }
269}
270
271if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
272    wpcom_rest_api_v2_load_plugin( __NAMESPACE__ . '\\WPCOM_REST_API_V2_Endpoint_VideoPress_Edits' );
273}