Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.15% covered (warning)
81.15%
198 / 244
66.67% covered (warning)
66.67%
8 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_VideoPress_Caption_Tracks
81.67% covered (warning)
81.67%
196 / 240
66.67% covered (warning)
66.67%
8 / 12
67.03
0.00% covered (danger)
0.00%
0 / 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%
48 / 48
100.00% covered (success)
100.00%
1 / 1
1
 save_track_args
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
2
 url_track_id
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 rest_permission_check
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 rest_list_tracks
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
6
 rest_save_track
81.67% covered (warning)
81.67%
49 / 60
0.00% covered (danger)
0.00%
0 / 1
18.78
 rest_delete_track
50.00% covered (danger)
50.00%
10 / 20
0.00% covered (danger)
0.00%
0 / 1
8.12
 prepare_track_response
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 sanitize_track_content
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
9
 track_too_large_error
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 current_user_can_edit_video
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
1<?php
2/**
3 * REST API endpoint for managing VideoPress caption tracks.
4 *
5 * @package automattic/jetpack-videopress
6 */
7
8namespace Automattic\Jetpack\VideoPress;
9
10if ( ! defined( 'ABSPATH' ) ) {
11    exit( 0 );
12}
13
14/**
15 * VideoPress caption tracks wpcom api v2 endpoint. Registered under `wpcom/v2`
16 * (rather than `jetpack/v4`) so the same routes serve WordPress.com Simple
17 * sites, which don't load the Jetpack namespace.
18 *
19 * @phan-constructor-used-for-side-effects
20 */
21class WPCOM_REST_API_V2_Endpoint_VideoPress_Caption_Tracks extends \WP_REST_Controller {
22
23    /**
24     * Upper bound on caption-cue blocks accepted in a single save.
25     *
26     * @var int
27     */
28    const MAX_CUE_BLOCKS = 5000;
29
30    /**
31     * Upper bound, in bytes, on submitted caption track content.
32     *
33     * @var int
34     */
35    const MAX_CONTENT_BYTES = 1048576;
36
37    /**
38     * Upper bound on tracks returned when listing a video's caption tracks.
39     *
40     * @var int
41     */
42    const TRACK_LIST_LIMIT = 500;
43
44    /**
45     * The namespace of this controller's route.
46     *
47     * @var string
48     */
49    protected $namespace = 'wpcom/v2';
50
51    /**
52     * The base of this controller's route.
53     *
54     * @var string
55     */
56    protected $rest_base = 'videopress/caption-tracks';
57
58    /**
59     * Metadata keys exposed in the REST payload.
60     *
61     * @var string[]
62     */
63    private static $meta_keys = array(
64        Caption_Tracks::META_GUID,
65        Caption_Tracks::META_KIND,
66        Caption_Tracks::META_SRC_LANG,
67        Caption_Tracks::META_LABEL,
68        Caption_Tracks::META_SOURCE_TRACK_KIND,
69        Caption_Tracks::META_SOURCE_TRACK_SRC_LANG,
70        Caption_Tracks::META_SOURCE_TRACK_SRC,
71    );
72
73    /**
74     * Constructor.
75     */
76    public function __construct() {
77        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
78    }
79
80    /**
81     * Register the routes.
82     */
83    public function register_routes() {
84        register_rest_route(
85            $this->namespace,
86            '/' . $this->rest_base,
87            array(
88                array(
89                    'methods'             => \WP_REST_Server::READABLE,
90                    'callback'            => array( __CLASS__, 'rest_list_tracks' ),
91                    'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
92                    'args'                => array(
93                        'guid' => array(
94                            'description' => __( 'VideoPress GUID.', 'jetpack-videopress-pkg' ),
95                            'type'        => 'string',
96                            'required'    => true,
97                        ),
98                    ),
99                ),
100                array(
101                    'methods'             => \WP_REST_Server::CREATABLE,
102                    'callback'            => array( __CLASS__, 'rest_save_track' ),
103                    'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
104                    'args'                => $this->save_track_args(),
105                ),
106            )
107        );
108
109        register_rest_route(
110            $this->namespace,
111            '/' . $this->rest_base . '/(?P<id>\d+)',
112            array(
113                array(
114                    // @phan-suppress-next-line PhanPluginMixedKeyNoKey -- register_rest_route() supports a shared `args` key alongside endpoint arrays.
115                    'methods'             => \WP_REST_Server::EDITABLE,
116                    'callback'            => array( __CLASS__, 'rest_save_track' ),
117                    'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
118                    'args'                => $this->save_track_args(),
119                ),
120                array(
121                    'methods'             => \WP_REST_Server::DELETABLE,
122                    'callback'            => array( __CLASS__, 'rest_delete_track' ),
123                    'permission_callback' => array( __CLASS__, 'rest_permission_check' ),
124                ),
125                'args' => array(
126                    'id' => array(
127                        'description' => __( 'Caption track ID.', 'jetpack-videopress-pkg' ),
128                        'type'        => 'integer',
129                        'required'    => true,
130                    ),
131                ),
132            )
133        );
134    }
135
136    /**
137     * Request schema for the caption track create/update routes.
138     *
139     * Type and shape validation only; the save handler still owns the semantic
140     * checks (GUID format, language tag, kind) so it can return specific errors.
141     *
142     * @return array
143     */
144    private function save_track_args() {
145        $meta_properties = array();
146        foreach ( self::$meta_keys as $key ) {
147            $meta_properties[ $key ] = array( 'type' => 'string' );
148        }
149
150        return array(
151            'guid'    => array(
152                'description' => __( 'VideoPress GUID.', 'jetpack-videopress-pkg' ),
153                'type'        => 'string',
154            ),
155            'title'   => array(
156                'description' => __( 'Caption track title.', 'jetpack-videopress-pkg' ),
157                'type'        => 'string',
158            ),
159            'content' => array(
160                'description' => __( 'Serialized caption-cue block content.', 'jetpack-videopress-pkg' ),
161                'type'        => 'string',
162            ),
163            'status'  => array(
164                'description' => __( 'Caption track status.', 'jetpack-videopress-pkg' ),
165                'type'        => 'string',
166                'enum'        => array( 'draft', 'publish' ),
167            ),
168            'meta'    => array(
169                'description'          => __( 'Caption track metadata.', 'jetpack-videopress-pkg' ),
170                'type'                 => 'object',
171                'required'             => true,
172                'properties'           => $meta_properties,
173                'additionalProperties' => false,
174            ),
175        );
176    }
177
178    /**
179     * The caption-track ID from the request's URL path, if any.
180     *
181     * Only the `(?P<id>\d+)` item routes supply one. Reading it from the URL
182     * params rather than `get_param()` â€” which also exposes query-string and body
183     * values regardless of a route's declared args â€” stops a spoofed `?id=` on the
184     * list/create routes from re-targeting the request at another video's track.
185     *
186     * @param \WP_REST_Request $request Incoming request.
187     * @return int Track ID, or 0 when the route path has none.
188     */
189    private static function url_track_id( \WP_REST_Request $request ) {
190        $url_params = $request->get_url_params();
191        return isset( $url_params['id'] ) ? (int) $url_params['id'] : 0;
192    }
193
194    /**
195     * REST permission callback for the caption track routes.
196     *
197     * Authorizes against the video the request targets: an existing track is
198     * authorized against the video it already belongs to, while a new track is
199     * authorized against the GUID supplied in the request.
200     *
201     * @param \WP_REST_Request $request Incoming request.
202     * @return bool
203     */
204    public static function rest_permission_check( \WP_REST_Request $request ) {
205        $track_id = self::url_track_id( $request );
206        if ( $track_id ) {
207            $existing = get_post( $track_id );
208            if ( $existing instanceof \WP_Post && Caption_Tracks::POST_TYPE === $existing->post_type ) {
209                /*
210                 * An existing track stays pinned to the video it already belongs
211                 * to; the request body can never re-target it. A track whose
212                 * stored GUID is empty is malformed and cannot be authorized, so
213                 * `current_user_can_edit_video` denies it rather than falling back
214                 * to the (attacker-controlled) body GUID.
215                 */
216                return self::current_user_can_edit_video(
217                    (string) get_post_meta( $track_id, Caption_Tracks::META_GUID, true )
218                );
219            }
220        }
221
222        $meta = (array) $request->get_param( 'meta' );
223        $guid = isset( $meta[ Caption_Tracks::META_GUID ] ) ? (string) $meta[ Caption_Tracks::META_GUID ] : (string) $request->get_param( 'guid' );
224
225        return self::current_user_can_edit_video( $guid );
226    }
227
228    /**
229     * List caption tracks for a VideoPress GUID.
230     *
231     * Responses include each track's full content so the editor can open a
232     * draft without a second fetch, so payload size scales with the number of
233     * languages times their content size (capped per track by MAX_CONTENT_BYTES).
234     *
235     * @param \WP_REST_Request $request Incoming request.
236     * @return \WP_REST_Response|\WP_Error
237     */
238    public static function rest_list_tracks( \WP_REST_Request $request ) {
239        $guid = Caption_Tracks::sanitize_guid( $request->get_param( 'guid' ) );
240        if ( empty( $guid ) ) {
241            return new \WP_Error(
242                'videopress_caption_track_invalid_guid',
243                esc_html__( 'A valid VideoPress GUID is required.', 'jetpack-videopress-pkg' ),
244                array( 'status' => 400 )
245            );
246        }
247
248        $query = new \WP_Query(
249            array(
250                'post_type'              => Caption_Tracks::POST_TYPE,
251                'post_status'            => 'any',
252                // Safety cap against unbounded queries, not pagination; no video should approach this many tracks.
253                'posts_per_page'         => self::TRACK_LIST_LIMIT,
254                'orderby'                => 'modified',
255                'order'                  => 'DESC',
256                'meta_key'               => Caption_Tracks::META_GUID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
257                'meta_value'             => $guid, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
258                'no_found_rows'          => true,
259                'update_post_term_cache' => false,
260            )
261        );
262
263        $tracks = array_map( array( __CLASS__, 'prepare_track_response' ), $query->posts );
264        return rest_ensure_response( $tracks );
265    }
266
267    /**
268     * Create or update a caption track.
269     *
270     * @param \WP_REST_Request $request Incoming request.
271     * @return \WP_REST_Response|\WP_Error
272     */
273    public static function rest_save_track( \WP_REST_Request $request ) {
274        $track_id = self::url_track_id( $request );
275        $existing = $track_id ? get_post( $track_id ) : null;
276
277        if ( $track_id && ( ! $existing || Caption_Tracks::POST_TYPE !== $existing->post_type ) ) {
278            return new \WP_Error(
279                'videopress_caption_track_not_found',
280                esc_html__( 'Caption track not found.', 'jetpack-videopress-pkg' ),
281                array( 'status' => 404 )
282            );
283        }
284
285        $meta = (array) $request->get_param( 'meta' );
286
287        $existing_guid = $existing ? (string) get_post_meta( $track_id, Caption_Tracks::META_GUID, true ) : '';
288        $guid          = Caption_Tracks::sanitize_guid(
289            '' !== $existing_guid ? $existing_guid : ( $meta[ Caption_Tracks::META_GUID ] ?? $request->get_param( 'guid' ) )
290        );
291
292        if ( empty( $guid ) ) {
293            return new \WP_Error(
294                'videopress_caption_track_invalid_guid',
295                esc_html__( 'A valid VideoPress GUID is required.', 'jetpack-videopress-pkg' ),
296                array( 'status' => 400 )
297            );
298        }
299
300        $src_lang = Caption_Tracks::sanitize_manual_language( $meta[ Caption_Tracks::META_SRC_LANG ] ?? '' );
301        if ( empty( $src_lang ) ) {
302            return new \WP_Error(
303                'videopress_caption_track_invalid_language',
304                esc_html__( 'A valid BCP-47 language tag is required.', 'jetpack-videopress-pkg' ),
305                array( 'status' => 400 )
306            );
307        }
308
309        $kind = Caption_Tracks::sanitize_kind( $meta[ Caption_Tracks::META_KIND ] ?? '' );
310        if ( empty( $kind ) ) {
311            return new \WP_Error(
312                'videopress_caption_track_invalid_kind',
313                esc_html__( 'A valid caption track kind is required.', 'jetpack-videopress-pkg' ),
314                array( 'status' => 400 )
315            );
316        }
317
318        /*
319         * Preserve an existing track's status when the request omits `status`
320         * (e.g. a label-only edit): treating a missing value as `draft` would
321         * silently unpublish a published caption track.
322         */
323        $requested_status = $request->get_param( 'status' );
324        if ( in_array( $requested_status, array( 'publish', 'draft' ), true ) ) {
325            $post_status = $requested_status;
326        } else {
327            $post_status = $existing ? $existing->post_status : 'draft';
328        }
329
330        $post_content = self::sanitize_track_content( $request->get_param( 'content' ) );
331        if ( is_wp_error( $post_content ) ) {
332            return $post_content;
333        }
334
335        $postarr = array(
336            'post_type'    => Caption_Tracks::POST_TYPE,
337            'post_title'   => sanitize_text_field( $request->get_param( 'title' ) ),
338            'post_content' => $post_content,
339            'post_status'  => $post_status,
340        );
341
342        if ( $track_id ) {
343            $postarr['ID'] = $track_id;
344            $result        = wp_update_post( wp_slash( $postarr ), true );
345        } else {
346            $result = wp_insert_post( wp_slash( $postarr ), true );
347        }
348
349        if ( is_wp_error( $result ) ) {
350            return $result;
351        }
352
353        $post_id                               = (int) $result;
354        $meta[ Caption_Tracks::META_GUID ]     = $guid;
355        $meta[ Caption_Tracks::META_SRC_LANG ] = $src_lang;
356        $meta[ Caption_Tracks::META_KIND ]     = $kind;
357
358        /*
359         * REST params arrive unslashed while the metadata layer unslashes on
360         * write, so slash the values to keep literal backslashes intact.
361         */
362        foreach ( self::$meta_keys as $key ) {
363            if ( array_key_exists( $key, $meta ) ) {
364                update_post_meta( $post_id, $key, wp_slash( $meta[ $key ] ) );
365            }
366        }
367
368        return rest_ensure_response( self::prepare_track_response( get_post( $post_id ) ) );
369    }
370
371    /**
372     * Delete a caption track.
373     *
374     * @param \WP_REST_Request $request Incoming request.
375     * @return \WP_REST_Response|\WP_Error
376     */
377    public static function rest_delete_track( \WP_REST_Request $request ) {
378        $track_id = self::url_track_id( $request );
379        $existing = $track_id ? get_post( $track_id ) : null;
380
381        if ( ! $existing || Caption_Tracks::POST_TYPE !== $existing->post_type ) {
382            return new \WP_Error(
383                'videopress_caption_track_not_found',
384                esc_html__( 'Caption track not found.', 'jetpack-videopress-pkg' ),
385                array( 'status' => 404 )
386            );
387        }
388
389        if ( ! wp_delete_post( $track_id, true ) ) {
390            return new \WP_Error(
391                'videopress_caption_track_delete_failed',
392                esc_html__( 'Unable to delete the caption track.', 'jetpack-videopress-pkg' ),
393                array( 'status' => 500 )
394            );
395        }
396
397        return rest_ensure_response(
398            array(
399                'deleted' => true,
400                'id'      => $track_id,
401            )
402        );
403    }
404
405    /**
406     * Prepare a caption track REST response.
407     *
408     * @param \WP_Post $post Caption track post.
409     * @return array
410     */
411    public static function prepare_track_response( \WP_Post $post ) {
412        $meta = array();
413        foreach ( self::$meta_keys as $key ) {
414            $meta[ $key ] = (string) get_post_meta( $post->ID, $key, true );
415        }
416
417        return array(
418            'id'      => (int) $post->ID,
419            'title'   => $post->post_title,
420            'content' => $post->post_content,
421            'status'  => $post->post_status,
422            'meta'    => $meta,
423        );
424    }
425
426    /**
427     * Sanitize serialized caption-cue block content.
428     *
429     * The client canonicalizes cue text before saving, but a direct API call
430     * could embed a WebVTT/comment terminator (`-->`) in a cue's text and corrupt
431     * block parsing or the WebVTT the track is later serialized to. Re-parse the
432     * blocks, keep only caption-cue blocks, neutralize the terminator in each
433     * cue's text, and rebuild each cue as a void block from its name and
434     * attributes only, so no submitted inner HTML survives into stored content.
435     *
436     * @param string $content Raw serialized block content.
437     * @return string|\WP_Error Sanitized block content, or an error when the payload exceeds the size caps.
438     */
439    private static function sanitize_track_content( $content ) {
440        $content = (string) $content;
441        if ( '' === trim( $content ) ) {
442            return '';
443        }
444
445        if ( strlen( $content ) > self::MAX_CONTENT_BYTES ) {
446            return self::track_too_large_error();
447        }
448
449        $sanitized = array();
450        foreach ( parse_blocks( $content ) as $block ) {
451            if ( Caption_Tracks::CUE_BLOCK_NAME !== ( $block['blockName'] ?? '' ) ) {
452                continue;
453            }
454
455            $attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
456            if ( isset( $attrs['text'] ) ) {
457                $attrs['text'] = str_replace(
458                    array( '--!>', '-->' ),
459                    '->',
460                    (string) $attrs['text']
461                );
462            }
463
464            $sanitized[] = array(
465                'blockName'    => Caption_Tracks::CUE_BLOCK_NAME,
466                'attrs'        => $attrs,
467                'innerBlocks'  => array(),
468                'innerHTML'    => '',
469                'innerContent' => array(),
470            );
471        }
472
473        if ( count( $sanitized ) > self::MAX_CUE_BLOCKS ) {
474            return self::track_too_large_error();
475        }
476
477        return serialize_blocks( $sanitized );
478    }
479
480    /**
481     * Error returned when submitted track content exceeds the size caps.
482     *
483     * @return \WP_Error
484     */
485    private static function track_too_large_error() {
486        return new \WP_Error(
487            'videopress_caption_track_too_large',
488            esc_html__( 'The caption track content is too large.', 'jetpack-videopress-pkg' ),
489            array( 'status' => 400 )
490        );
491    }
492
493    /**
494     * Whether the current user may manage caption tracks for a VideoPress GUID.
495     *
496     * Editing a video's captions requires the ability to edit that video. Where
497     * the GUID resolver is unavailable (an environment without a local
498     * attachment store, e.g. WordPress.com) ownership can't be verified, so the
499     * fallback requires `edit_others_posts` rather than the far broader
500     * `upload_files`: without it any author could manage another author's video
501     * captions. The resolver ships with this package, so this fallback is a rare
502     * last resort.
503     *
504     * @param string $guid VideoPress GUID.
505     * @return bool
506     */
507    private static function current_user_can_edit_video( $guid ) {
508        $guid = Caption_Tracks::sanitize_guid( $guid );
509
510        if ( '' === $guid ) {
511            return false;
512        }
513
514        if ( function_exists( 'videopress_get_post_by_guid' ) ) {
515            $attachment = videopress_get_post_by_guid( $guid );
516
517            return $attachment instanceof \WP_Post && current_user_can( 'edit_post', $attachment->ID );
518        }
519
520        return current_user_can( 'edit_others_posts' );
521    }
522}
523
524if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
525    wpcom_rest_api_v2_load_plugin( 'Automattic\Jetpack\VideoPress\WPCOM_REST_API_V2_Endpoint_VideoPress_Caption_Tracks' );
526}