Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.32% covered (warning)
73.32%
470 / 641
30.00% covered (danger)
30.00%
6 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_VideoPress
73.31% covered (warning)
73.31%
467 / 637
30.00% covered (danger)
30.00%
6 / 20
377.20
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
98.76% covered (success)
98.76%
238 / 241
0.00% covered (danger)
0.00%
0 / 1
12
 videopress_get_settings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 videopress_update_settings
82.76% covered (warning)
82.76%
24 / 29
0.00% covered (danger)
0.00%
0 / 1
10.51
 videopress_promote_attachment
100.00% covered (success)
100.00%
82 / 82
100.00% covered (success)
100.00%
1 / 1
18
 promote_lock_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 promote_is_available
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 promote_site_has_videopress
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 promote_load_primitives
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 promote_video_info
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 promote_find_any_guid
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 promote_transcode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 videopress_video_belong_to_site
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 wpcom_poster_request
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 get_video_attachment_id
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
9.30
 videopress_block_update_poster
21.05% covered (danger)
21.05%
4 / 19
0.00% covered (danger)
0.00%
0 / 1
3.97
 videopress_block_get_poster
30.77% covered (danger)
30.77%
4 / 13
0.00% covered (danger)
0.00%
0 / 1
3.33
 videopress_upload_jwt
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
20
 videopress_playback_jwt
51.35% covered (warning)
51.35%
19 / 37
0.00% covered (danger)
0.00%
0 / 1
7.88
 videopress_block_update_meta
58.47% covered (warning)
58.47%
69 / 118
0.00% covered (danger)
0.00%
0 / 1
105.32
1<?php
2/**
3 * REST API endpoint for managing VideoPress metadata.
4 *
5 * @package automattic/jetpack
6 * @since-jetpack 9.3.0
7 * @since 0.1.3
8 */
9
10namespace Automattic\Jetpack\VideoPress;
11
12use Automattic\Jetpack\Connection\Client;
13use Automattic\Jetpack\Constants;
14use WP_Error;
15use WP_REST_Controller;
16use WP_REST_Request;
17use WP_REST_Response;
18use WP_REST_Server;
19
20if ( ! defined( 'ABSPATH' ) ) {
21    exit( 0 );
22}
23
24/**
25 * VideoPress wpcom api v2 endpoint
26 *
27 * @phan-constructor-used-for-side-effects
28 */
29class WPCOM_REST_API_V2_Endpoint_VideoPress extends WP_REST_Controller {
30    /**
31     * Constructor.
32     */
33    public function __construct() {
34        $this->namespace = 'wpcom/v2';
35        $this->rest_base = 'videopress';
36
37        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
38    }
39
40    /**
41     * Register the route.
42     */
43    public function register_routes() {
44        // Meta Route.
45        register_rest_route(
46            $this->namespace,
47            $this->rest_base . '/meta',
48            array(
49                'args'                => array(
50                    'id'              => array(
51                        'description' => __( 'The post id for the attachment.', 'jetpack-videopress-pkg' ),
52                        'type'        => 'integer',
53                        'required'    => true,
54                    ),
55                    'title'           => array(
56                        'description'       => __( 'The title of the video.', 'jetpack-videopress-pkg' ),
57                        'type'              => 'string',
58                        'sanitize_callback' => 'sanitize_text_field',
59                    ),
60                    'description'     => array(
61                        'description'       => __( 'The description of the video.', 'jetpack-videopress-pkg' ),
62                        'type'              => 'string',
63                        'sanitize_callback' => 'sanitize_textarea_field',
64                    ),
65                    'caption'         => array(
66                        'description'       => __( 'The caption of the video.', 'jetpack-videopress-pkg' ),
67                        'type'              => 'string',
68                        'sanitize_callback' => 'sanitize_textarea_field',
69                    ),
70                    'rating'          => array(
71                        'description'       => __( 'The video content rating. One of G, PG-13 or R-17', 'jetpack-videopress-pkg' ),
72                        'type'              => 'string',
73                        'sanitize_callback' => 'sanitize_text_field',
74                    ),
75                    'display_embed'   => array(
76                        'description' => __( 'Display the share menu in the player.', 'jetpack-videopress-pkg' ),
77                        'type'        => 'boolean',
78                    ),
79                    'allow_download'  => array(
80                        'description' => __( 'Display download option and allow viewers to download this video', 'jetpack-videopress-pkg' ),
81                        'type'        => 'boolean',
82                    ),
83                    'privacy_setting' => array(
84                        'description' => __( 'How to determine if the video should be public or private', 'jetpack-videopress-pkg' ),
85                        'type'        => 'integer',
86                        'enum'        => array(
87                            \VIDEOPRESS_PRIVACY::IS_PUBLIC,
88                            \VIDEOPRESS_PRIVACY::IS_PRIVATE,
89                            \VIDEOPRESS_PRIVACY::SITE_DEFAULT,
90                        ),
91                    ),
92                ),
93                'methods'             => WP_REST_Server::EDITABLE,
94                'callback'            => array( $this, 'videopress_block_update_meta' ),
95                'permission_callback' => function ( $request ) {
96                    if ( ! Data::can_perform_action() ) {
97                        return false;
98                    }
99                    // Authorize against the specific attachment the request targets,
100                    // not just the generic edit_posts capability. `id` is read from
101                    // the JSON body to match videopress_block_update_meta(), so a
102                    // Contributor cannot modify (or, via a privacy downgrade, expose)
103                    // another user's video.
104                    $params  = $request->get_json_params();
105                    $post_id = isset( $params['id'] ) ? (int) $params['id'] : 0;
106                    return current_user_can( 'edit_post', $post_id );
107                },
108            )
109        );
110
111        // Poster Route.
112        register_rest_route(
113            $this->namespace,
114            $this->rest_base . '/(?P<video_guid>[A-Za-z0-9]{8})/poster',
115            array(
116                'args' => array(
117                    'video_guid' => array(
118                        'description' => __( 'The VideoPress GUID.', 'jetpack-videopress-pkg' ), // @phan-suppress-current-line PhanPluginMixedKeyNoKey
119                        'type'        => 'string',
120                        'required'    => true,
121                    ),
122                ),
123                array(
124                    'methods'             => WP_REST_Server::READABLE,
125                    'callback'            => array( $this, 'videopress_block_get_poster' ),
126                    'permission_callback' => function ( $request ) {
127                        // Reading a poster/frame exposes video content, so require the
128                        // same per-video view authorization as playback in addition to
129                        // `read`. This closes a Subscriber+ read of any private video's
130                        // poster/frame by guid.
131                        //
132                        // `read` is kept because is_current_user_authed_for_video() has
133                        // no logged-out bail and returns true for an effectively public
134                        // video, so dropping it would make this route reachable
135                        // anonymously -- an unauthenticated amplifier for the two
136                        // outbound WordPress.com requests the handler makes.
137                        return current_user_can( 'read' )
138                            && Access_Control::instance()->is_current_user_authed_for_video( $request->get_param( 'video_guid' ), 0 );
139                    },
140                ),
141                array(
142                    'args'                => array(
143                        'at_time'              => array(
144                            'description' => __( 'The time in the video to use as the poster frame.', 'jetpack-videopress-pkg' ),
145                            'type'        => 'integer',
146                        ),
147                        'is_millisec'          => array(
148                            'description' => __( 'Whether the time is in milliseconds or seconds.', 'jetpack-videopress-pkg' ),
149                            'type'        => 'boolean',
150                        ),
151                        'poster_attachment_id' => array(
152                            'description' => __( 'The attachment id of the poster image.', 'jetpack-videopress-pkg' ),
153                            'type'        => 'integer',
154                        ),
155                    ),
156                    'methods'             => WP_REST_Server::EDITABLE,
157                    'callback'            => array( $this, 'videopress_block_update_poster' ),
158                    'permission_callback' => function ( $request ) {
159                        // Authorize the poster write against the specific video rather
160                        // than the site-wide upload_files capability alone, so an author
161                        // cannot overwrite the poster of another user's video by guid.
162                        //
163                        // upload_files is kept as a floor: for an attachment
164                        // (post_status 'inherit') core maps edit_post to edit_posts for
165                        // the post author, which a Contributor has and which does not
166                        // imply the media rights this route needs.
167                        if ( ! Data::can_perform_action() || ! current_user_can( 'upload_files' ) ) {
168                            return false;
169                        }
170
171                        $attachment_id = self::get_video_attachment_id( $request->get_param( 'video_guid' ) );
172                        if ( ! $attachment_id ) {
173                            return new WP_Error(
174                                'videopress_attachment_not_found',
175                                __( 'This video could not be found in the Media Library. Restore it from the trash, if available, and try again.', 'jetpack-videopress-pkg' ),
176                                array( 'status' => 403 )
177                            );
178                        }
179
180                        return current_user_can( 'edit_post', $attachment_id );
181                    },
182                ),
183            )
184        );
185
186        // Endpoint to know if the video metadata is editable.
187        register_rest_route(
188            $this->namespace,
189            $this->rest_base . '/(?P<video_guid>[A-Za-z0-9]{8})/check-ownership/(?P<post_id>\d+)/',
190            array(
191                'args' => array(
192                    'video_guid' => array(
193                        'description' => __( 'The VideoPress GUID.', 'jetpack-videopress-pkg' ), // @phan-suppress-current-line PhanPluginMixedKeyNoKey
194                        'type'        => 'string',
195                        'required'    => true,
196                    ),
197                    'post_id'    => array(
198                        'description' => __( 'The post id for the attachment.', 'jetpack-videopress-pkg' ),
199                        'type'        => 'integer',
200                        'required'    => true,
201                    ),
202                ),
203                array(
204                    'methods'             => WP_REST_Server::READABLE,
205                    'callback'            => array( $this, 'videopress_video_belong_to_site' ),
206                    'permission_callback' => function () {
207                        return Data::can_perform_action() && current_user_can( 'upload_files' );
208                    },
209                ),
210            )
211        );
212
213        // Token Route.
214        register_rest_route(
215            $this->namespace,
216            $this->rest_base . '/upload-jwt',
217            array(
218                'methods'             => \WP_REST_Server::EDITABLE,
219                'callback'            => array( $this, 'videopress_upload_jwt' ),
220                'permission_callback' => function () {
221                    return Data::can_perform_action() && current_user_can( 'upload_files' );
222                },
223            )
224        );
225
226        // Playback Token Route.
227        register_rest_route(
228            $this->namespace,
229            $this->rest_base . '/playback-jwt/(?P<video_guid>[A-Za-z0-9]{8})',
230            array(
231                'args'                => array(
232                    'video_guid'           => array(
233                        'description' => __( 'The VideoPress GUID.', 'jetpack-videopress-pkg' ),
234                        'type'        => 'string',
235                        'required'    => true,
236                    ),
237                    'post_id'              => array(
238                        'description' => __( 'The post the video is embedded in, used to authorize access.', 'jetpack-videopress-pkg' ),
239                        'type'        => 'integer',
240                        'required'    => false,
241                    ),
242                    'subscription_plan_id' => array(
243                        'description' => __( 'The subscription plan the premium-content block gating the video uses.', 'jetpack-videopress-pkg' ),
244                        'type'        => 'integer',
245                        'required'    => false,
246                    ),
247                ),
248                'methods'             => \WP_REST_Server::EDITABLE,
249                'callback'            => array( $this, 'videopress_playback_jwt' ),
250                'permission_callback' => function () {
251                    return current_user_can( 'read' );
252                },
253            )
254        );
255
256        // Settings Routes. Primarily for WordPress.com Simple, where the
257        // videopress/v1 namespace never reaches the REST dispatcher; the
258        // routes also register self-hosted as a harmless duplicate of
259        // videopress/v1/settings (the callbacks are host-safe).
260        register_rest_route(
261            $this->namespace,
262            $this->rest_base . '/settings',
263            array(
264                array(
265                    'methods'             => WP_REST_Server::READABLE,
266                    'callback'            => array( $this, 'videopress_get_settings' ),
267                    'permission_callback' => function () {
268                        return current_user_can( 'manage_options' );
269                    },
270                ),
271                array(
272                    'methods'             => WP_REST_Server::EDITABLE,
273                    'callback'            => array( $this, 'videopress_update_settings' ),
274                    'permission_callback' => function () {
275                        return Data::can_perform_action() && current_user_can( 'manage_options' );
276                    },
277                    'args'                => array(
278                        'videopress_videos_private_for_site' => array(
279                            'description' => __( 'If the VideoPress videos should be private by default', 'jetpack-videopress-pkg' ),
280                            'type'        => 'boolean',
281                        ),
282                        'videopress_auto_subtitles_disabled' => array(
283                            'description' => __( 'If auto-generated subtitles should be skipped for new videos', 'jetpack-videopress-pkg' ),
284                            'type'        => 'boolean',
285                        ),
286                        'videopress_player_preload_disabled' => array(
287                            'description' => __( 'If embedded players should wait for playback before preloading video data', 'jetpack-videopress-pkg' ),
288                            'type'        => 'boolean',
289                        ),
290                        'videopress_inline_player_enabled' => array(
291                            'description' => __( 'If videos should render an inline player from one shared script instead of one frame per video', 'jetpack-videopress-pkg' ),
292                            'type'        => 'boolean',
293                        ),
294                        'videopress_share_menu_disabled'   => array(
295                            'description' => __( 'If the share menu should be hidden on every video, overriding each video’s own setting', 'jetpack-videopress-pkg' ),
296                            'type'        => 'boolean',
297                        ),
298                    ),
299                ),
300            )
301        );
302
303        // Promote Route. WordPress.com Simple only: turn an existing local
304        // video attachment into a VideoPress video in-process. The file
305        // already lives on WordPress.com storage, so unlike the self-hosted
306        // flow (which walks videopress/v1/upload/{id} pushing tus chunks)
307        // promotion is a single call: create the global videos-table row and
308        // enqueue the transcode. Lives in wpcom/v2 because videopress/v1
309        // never reaches the REST dispatcher on Simple; the callback returns
310        // a clean error on every other host.
311        register_rest_route(
312            $this->namespace,
313            $this->rest_base . '/promote/(?P<attachment_id>\d+)',
314            array(
315                'args'                => array(
316                    'attachment_id' => array(
317                        'description' => __( 'The attachment id of the video to promote.', 'jetpack-videopress-pkg' ),
318                        'type'        => 'integer',
319                        'required'    => true,
320                    ),
321                    'source'        => array(
322                        'description' => __( 'The entry surface that initiated this promotion.', 'jetpack-videopress-pkg' ),
323                        'type'        => 'string',
324                        'enum'        => array( 'dashboard_promote', 'block_editor' ),
325                    ),
326                ),
327                'methods'             => WP_REST_Server::EDITABLE,
328                'callback'            => array( $this, 'videopress_promote_attachment' ),
329                'permission_callback' => function ( $request ) {
330                    // videopress/v1/upload (the self-hosted equivalent) never reaches
331                    // the REST dispatcher on WordPress.com Simple; promotion is the
332                    // Simple path and acts on a caller-supplied attachment id, so in
333                    // addition to upload_files it needs the same per-object check to
334                    // avoid a cross-user IDOR.
335                    if ( ! Data::can_perform_action() || ! current_user_can( 'upload_files' ) ) {
336                        return false;
337                    }
338                    return current_user_can( 'edit_post', (int) $request->get_param( 'attachment_id' ) );
339                },
340            )
341        );
342    }
343
344    /**
345     * Returns the VideoPress site settings.
346     *
347     * `Data::get_videopress_settings()` is already IS_WPCOM-aware (site
348     * privacy / site type resolution), so the same callback serves every
349     * host.
350     *
351     * @return WP_REST_Response The response object.
352     */
353    public function videopress_get_settings() {
354        return rest_ensure_response( Data::get_videopress_settings() );
355    }
356
357    /**
358     * Updates the VideoPress site settings.
359     *
360     * Mirrors `VideoPress_Rest_Api_V1_Settings::update_settings()`, except
361     * on WPCOM `videopress_videos_private_for_site` is not honored:
362     * `videopress_private_enabled_for_site` is a dead option on Simple,
363     * where the site-default privacy derives from the site's own privacy
364     * setting. When a caller supplies that param on WPCOM it is not
365     * persisted, and the response reports it under `ignored` so consumers
366     * are not told a write succeeded when it was silently discarded.
367     *
368     * @param WP_REST_Request $request The request object.
369     * @return WP_REST_Response The response object.
370     */
371    public function videopress_update_settings( $request ) {
372        $private_for_site        = $request->get_param( 'videopress_videos_private_for_site' );
373        $auto_subtitles_disabled = $request->get_param( 'videopress_auto_subtitles_disabled' );
374        $player_preload_disabled = $request->get_param( 'videopress_player_preload_disabled' );
375        $inline_player_enabled   = $request->get_param( 'videopress_inline_player_enabled' );
376        $share_menu_disabled     = $request->get_param( 'videopress_share_menu_disabled' );
377
378        $ignored = array();
379
380        // On WordPress.com Simple the site-default privacy derives from the
381        // site's own privacy setting, so `videopress_private_enabled_for_site`
382        // is a dead option. Drop the param rather than pretend to persist it,
383        // and surface it as ignored so the response stays truthful.
384        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
385            if ( null !== $private_for_site ) {
386                $ignored[] = 'videopress_videos_private_for_site';
387            }
388            $private_for_site = null;
389        }
390
391        if ( null !== $private_for_site ) {
392            update_option( 'videopress_private_enabled_for_site', $private_for_site );
393        }
394
395        if ( null !== $auto_subtitles_disabled ) {
396            update_option( 'videopress_auto_subtitles_disabled', $auto_subtitles_disabled );
397        }
398
399        if ( null !== $player_preload_disabled ) {
400            update_option( 'videopress_player_preload_disabled', $player_preload_disabled );
401        }
402
403        if ( null !== $inline_player_enabled ) {
404            update_option( 'videopress_inline_player_enabled', $inline_player_enabled );
405        }
406
407        if ( null !== $share_menu_disabled ) {
408            update_option( 'videopress_share_menu_disabled', $share_menu_disabled );
409        }
410
411        $response = array(
412            'code'    => 'success',
413            'message' => __( 'VideoPress settings updated successfully.', 'jetpack-videopress-pkg' ),
414            'data'    => 200,
415        );
416
417        if ( ! empty( $ignored ) ) {
418            $response['ignored'] = $ignored;
419            $response['message'] = __( 'VideoPress settings updated. Some settings are not configurable on this site and were ignored.', 'jetpack-videopress-pkg' );
420        }
421
422        return rest_ensure_response( $response );
423    }
424
425    /**
426     * Promote an existing local video attachment to VideoPress. WordPress.com
427     * Simple only.
428     *
429     * The population this serves: sites that uploaded videos on a plan
430     * without VideoPress and later upgraded to one that includes it â€” their
431     * pre-upgrade videos are plain attachments with no path onto VideoPress
432     * (the dashboard's self-hosted promote flow can't run on Simple).
433     *
434     * Promotion is in-place: the same attachment id gains a row in the
435     * global videos table â€” no sibling attachment is created and no
436     * `_videopress_uploaded_id` marker is written (that is the self-hosted
437     * sibling convention). The handler calls the exact primitive every
438     * direct upload to a VideoPress-enabled Simple site flows through via
439     * its `add_attachment` hook: `remote_transcode_one_video()`.
440     *
441     * @param WP_REST_Request $request The request object.
442     * @return WP_REST_Response|WP_Error
443     */
444    public function videopress_promote_attachment( $request ) {
445        if ( ! $this->promote_is_available() ) {
446            return new WP_Error(
447                'videopress_promote_not_available',
448                __( 'Promoting local videos is only available on WordPress.com sites.', 'jetpack-videopress-pkg' ),
449                array( 'status' => 404 )
450            );
451        }
452
453        $attachment_id = (int) $request->get_param( 'attachment_id' );
454        $blog_id       = get_current_blog_id();
455
456        $post = get_post( $attachment_id );
457        if ( ! $post || 'attachment' !== $post->post_type || 'trash' === $post->post_status || ! wp_attachment_is( 'video', $post ) ) {
458            return new WP_Error(
459                'videopress_promote_invalid_attachment',
460                __( 'The attachment is not a video in this site’s media library.', 'jetpack-videopress-pkg' ),
461                array( 'status' => 404 )
462            );
463        }
464
465        /*
466         * Plan gate. The native path enforces VideoPress at upload/mime time
467         * (wpcom_site_can_upload_videos()) and remote_transcode_one_video()
468         * itself checks nothing â€” without this, a site whose plan allows
469         * plain video uploads but not VideoPress could enqueue transcodes.
470         */
471        if ( ! $this->promote_site_has_videopress( $blog_id ) ) {
472            return new WP_Error(
473                'videopress_promote_not_allowed',
474                __( 'This site’s plan does not include VideoPress.', 'jetpack-videopress-pkg' ),
475                array( 'status' => 403 )
476            );
477        }
478
479        if ( ! $this->promote_load_primitives() ) {
480            return new WP_Error(
481                'videopress_promote_unavailable',
482                __( 'VideoPress is not available right now. Please try again later.', 'jetpack-videopress-pkg' ),
483                array( 'status' => 500 )
484            );
485        }
486
487        /*
488         * Already on VideoPress? Report success idempotently. Cache-busted
489         * read: the wpcom delete path does clean this key, but a stale 12h
490         * 'video-info' entry must not misreport here â€” and the fresh read
491         * re-primes the cache the primitive's own (non-busted) lookup uses.
492         */
493        $info = $this->promote_video_info( $blog_id, $attachment_id );
494        if ( $info && ! empty( $info->guid ) ) {
495            return rest_ensure_response(
496                array(
497                    'guid'               => $info->guid,
498                    'media_id'           => $attachment_id,
499                    'already_videopress' => true,
500                )
501            );
502        }
503
504        /*
505         * A soft-deleted VideoPress row may still occupy this attachment's
506         * slot: the videos table's primary key is (blog_id, post_id), so a
507         * tombstoned row makes video_create_info()'s insert fail silently
508         * and the fresh promote below would report an unexplained failure.
509         * (The tombstoned attachment renders as an ordinary local video â€”
510         * the REST fields only see live rows â€” so the UI can reach this.)
511         * Detect it and answer honestly instead. Resurrecting the row via
512         * the primitive's $redo path is a possible follow-up, but it needs
513         * rollback semantics this endpoint doesn't want to own yet.
514         */
515        if ( $this->promote_find_any_guid( $blog_id, $attachment_id ) ) {
516            return new WP_Error(
517                'videopress_promote_previously_deleted',
518                __( 'This video was previously deleted from VideoPress, so it can’t be promoted automatically. Please upload it as a new video instead.', 'jetpack-videopress-pkg' ),
519                array( 'status' => 409 )
520            );
521        }
522
523        /*
524         * remote_transcode_one_video() derives the transcoder's fetch URL
525         * from the attached file's blogs.dir path with an unguarded regex; a
526         * non-matching path (some imports/migrations) would still create the
527         * videos row and enqueue a malformed job that renders as
528         * "Processing" forever. Validate with the same pattern first
529         * (verbatim, unescaped dot included) and fail clean.
530         */
531        $path = get_attached_file( $attachment_id );
532        if ( ! $path || ! preg_match( '|/wp-content/blogs.dir\S+?files(.+)$|i', $path ) ) {
533            return new WP_Error(
534                'videopress_promote_unsupported_file',
535                __( 'This video’s file cannot be promoted automatically. Please download it and upload it again.', 'jetpack-videopress-pkg' ),
536                array( 'status' => 400 )
537            );
538        }
539
540        /*
541         * Best-effort mutex around the primitive: the pre-checks above are
542         * check-then-act, and remote_transcode_one_video() ignores
543         * video_create_info()'s outcome and queues its transcode job
544         * unconditionally â€” so two near-simultaneous promotes (double-click,
545         * two tabs) would transcode the same video twice. wp_cache_add() is
546         * atomic on the wpcom object cache; the TTL comfortably outlives the
547         * primitive's sleep(3) and self-heals if the request dies mid-hold.
548         */
549        $promote_lock = $this->promote_lock_key( $blog_id, $attachment_id );
550        if ( ! wp_cache_add( $promote_lock, 1, 'video-info', 30 ) ) {
551            return new WP_Error(
552                'videopress_promote_in_progress',
553                __( 'This video is already being promoted to VideoPress.', 'jetpack-videopress-pkg' ),
554                array( 'status' => 409 )
555            );
556        }
557
558        // A competing promotion may have finished between the first lookup and acquiring the lock.
559        $info = $this->promote_video_info( $blog_id, $attachment_id );
560        if ( $info && ! empty( $info->guid ) ) {
561            wp_cache_delete( $promote_lock, 'video-info' );
562            return rest_ensure_response(
563                array(
564                    'guid'               => $info->guid,
565                    'media_id'           => $attachment_id,
566                    'already_videopress' => true,
567                )
568            );
569        }
570
571        /*
572         * Creates the videos-table row (video_create_info()) and enqueues
573         * the async transcode job. The fresh-upload path sleep(3)s before
574         * queueing (DB-write settling), so this request takes ~3s.
575         */
576        $this->promote_transcode( $attachment_id );
577
578        /*
579         * The primitive returns bare false for every bail reason (missing
580         * attachment, already transcoded, â€¦), so verify by re-reading the
581         * videos table instead of trusting the return value.
582         */
583        $info = $this->promote_video_info( $blog_id, $attachment_id );
584
585        wp_cache_delete( $promote_lock, 'video-info' );
586
587        if ( ! $info || empty( $info->guid ) ) {
588            return new WP_Error(
589                'videopress_promote_failed',
590                __( 'The video could not be promoted to VideoPress. Please try again later.', 'jetpack-videopress-pkg' ),
591                array( 'status' => 500 )
592            );
593        }
594
595        /**
596         * Fires after a new in-process VideoPress registration is verified, before processing completes.
597         *
598         * @since 0.62.0
599         *
600         * @param object      $info    Canonical video registration.
601         * @param int         $blog_id Site where the attachment was promoted.
602         * @param string|null $source  Client-reported entry surface, or null when omitted.
603         */
604        do_action( 'jetpack_videopress_attachment_promoted', $info, $blog_id, $request->get_param( 'source' ) );
605
606        return rest_ensure_response(
607            array(
608                'guid'     => $info->guid,
609                'media_id' => $attachment_id,
610            )
611        );
612    }
613
614    /*
615     * The five methods below are the promote flow's wpcom seams. They exist
616     * so the orchestration above is unit-testable: monorepo CI can never
617     * define IS_WPCOM, so without them every branch past the host guard
618     * would be dead code under test. A WorDBless test double overrides
619     * exactly these (and nothing else) to exercise the real ordering,
620     * error contract, and mutex behavior.
621     */
622
623    /**
624     * The object-cache key serializing promotes of one attachment.
625     *
626     * @param int $blog_id       The blog id.
627     * @param int $attachment_id The attachment id.
628     * @return string
629     */
630    protected function promote_lock_key( $blog_id, $attachment_id ) {
631        return "videopress-promote-{$blog_id}-{$attachment_id}";
632    }
633
634    /**
635     * Whether the in-process promote flow is available on this host.
636     *
637     * @return bool
638     */
639    protected function promote_is_available() {
640        return defined( 'IS_WPCOM' ) && IS_WPCOM;
641    }
642
643    /**
644     * Whether the site's plan includes VideoPress.
645     *
646     * @param int $blog_id The blog to check.
647     * @return bool
648     */
649    protected function promote_site_has_videopress( $blog_id ) {
650        return function_exists( 'wpcom_site_has_videopress' ) && wpcom_site_has_videopress( $blog_id );
651    }
652
653    /**
654     * Ensure the wpcom transcode primitives are loaded.
655     *
656     * Public-api requests define ADMIN_PLUGINS, so they normally already
657     * are; this mirrors the wpcom TUS uploader's
658     * ensure_wpcom_admin_includes_present() guard for any context where
659     * they aren't. Each file is existence-checked individually so a
660     * partially-moved set mid-deploy degrades to the handler's clean error
661     * rather than a require fatal.
662     *
663     * @return bool Whether the primitives are callable.
664     */
665    protected function promote_load_primitives() {
666        if ( ! function_exists( 'remote_transcode_one_video' ) && defined( 'ABSPATH' ) ) {
667            $transcode_includes = array(
668                'class.videopress-job-base.php',
669                'class.video-job-thumbnails.php',
670                'class.video-thumbnailer.php',
671                'video-transcoder.php',
672                'transcode.php',
673            );
674            foreach ( $transcode_includes as $transcode_include ) {
675                $transcode_include_path = ABSPATH . 'wp-content/admin-plugins/videopress/' . $transcode_include;
676                if ( file_exists( $transcode_include_path ) ) {
677                    require_once $transcode_include_path;
678                }
679            }
680        }
681
682        return function_exists( 'remote_transcode_one_video' ) && function_exists( 'video_get_info_by_blogpostid' );
683    }
684
685    /**
686     * Cache-busted read of the live videos-table row for an attachment.
687     *
688     * @param int $blog_id       The blog id.
689     * @param int $attachment_id The attachment id.
690     * @return object|false The video info object, or false when no live row exists.
691     */
692    protected function promote_video_info( $blog_id, $attachment_id ) {
693        return video_get_info_by_blogpostid( $blog_id, $attachment_id, true );
694    }
695
696    /**
697     * Find any videos-table guid for the attachment, tombstoned included â€”
698     * the live-row helper can't see soft-deleted rows.
699     *
700     * @param int $blog_id       The blog id.
701     * @param int $attachment_id The attachment id.
702     * @return string|null The guid, or null when no row exists at all.
703     */
704    protected function promote_find_any_guid( $blog_id, $attachment_id ) {
705        global $wpdb;
706        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- wpcom global table; must see tombstoned rows and must not be cached.
707        return $wpdb->get_var( $wpdb->prepare( 'SELECT guid FROM videos WHERE blog_id = %d AND post_id = %d', $blog_id, $attachment_id ) );
708    }
709
710    /**
711     * Run the wpcom promote primitive for an attachment.
712     *
713     * @param int $attachment_id The attachment id.
714     * @return void
715     */
716    protected function promote_transcode( $attachment_id ) {
717        remote_transcode_one_video( $attachment_id ); // @phan-suppress-current-line PhanUndeclaredFunction -- wpcom-only (admin-plugins/videopress/transcode.php), promote_load_primitives()-guarded; not in the generated wpcom stubs yet.
718    }
719
720    /**
721     * Check whether the video belongs to the current site,
722     * considering the given post_id and the video_guid.
723     *
724     * @param WP_REST_Request $request The request object.
725     * @return WP_REST_Response True if the video belongs to the current site, false otherwise.
726     */
727    public function videopress_video_belong_to_site( $request ) {
728        $post_id    = $request->get_param( 'post_id' );
729        $video_guid = $request->get_param( 'video_guid' );
730
731        if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
732            $found_guid = get_post_meta( $post_id, 'videopress_guid', true );
733        } else {
734            $blog_id    = get_current_blog_id();
735            $info       = video_get_info_by_blogpostid( $blog_id, $post_id );
736            $found_guid = $info ? $info->guid : '';
737        }
738
739        if ( ! $found_guid ) {
740            return rest_ensure_response( array( 'video-belong-to-site' => false ) );
741        }
742
743        return rest_ensure_response( array( 'video-belong-to-site' => $found_guid === $video_guid ) );
744    }
745
746    /**
747     * Hit WPCOM poster endpoint.
748     *
749     * @param string $video_guid  The VideoPress GUID.
750     * @param array  $args        Request args.
751     * @param array  $body        Request body.
752     * @param string $query       Request query.
753     * @return WP_REST_Response|WP_Error
754     */
755    public function wpcom_poster_request( $video_guid, $args, $body = null, $query = '' ) {
756        $query    = $query !== '' ? '?' . $query : '';
757        $endpoint = 'videos/' . $video_guid . '/poster' . $query;
758
759        $url = sprintf(
760            '%s/%s/v%s/%s',
761            Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' ),
762            'rest',
763            '1.1',
764            $endpoint
765        );
766
767        $request_args = array_merge( $args, array( 'body' => $body ) );
768
769        // @phan-suppress-next-line PhanAccessMethodInternal -- Phan is correct, but the usage is intentional.
770        $result = Client::_wp_remote_request( $url, $request_args );
771
772        if ( is_wp_error( $result ) ) {
773            return rest_ensure_response( $result );
774        }
775
776        $response = $result['http_response'];
777
778        $status = $response->get_status();
779
780        $data = array(
781            'code' => $status,
782            'data' => json_decode( $response->get_data(), true ),
783        );
784
785        return rest_ensure_response(
786            new WP_REST_Response( $data, $status )
787        );
788    }
789
790    /**
791     * Resolve a VideoPress guid to its local attachment id on the current site.
792     *
793     * Returns 0 when the GUID does not belong to the current site.
794     *
795     * @internal
796     *
797     * @param string $video_guid The VideoPress GUID.
798     * @return int The attachment/post id, or 0 if it cannot be resolved.
799     */
800    public static function get_video_attachment_id( $video_guid ) {
801        if ( empty( $video_guid ) ) {
802            return 0;
803        }
804
805        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
806            $video_info = video_get_info_by_guid( $video_guid );
807            if ( empty( $video_info ) || (int) $video_info->blog_id !== get_current_blog_id() ) {
808                return 0;
809            }
810            return (int) $video_info->post_id;
811        }
812
813        // utility-functions.php is not loaded in every configuration, so fail
814        // closed rather than fatal if the resolver is unavailable.
815        if ( ! function_exists( 'videopress_get_post_by_guid' ) ) {
816            return 0;
817        }
818
819        $attachment = videopress_get_post_by_guid( $video_guid );
820        return $attachment ? (int) $attachment->ID : 0;
821    }
822
823    /**
824     * Update the a poster image via the WPCOM REST API.
825     *
826     * @param WP_REST_Request $request The request object.
827     * @return WP_REST_Response|WP_Error
828     */
829    public function videopress_block_update_poster( $request ) {
830        try {
831            $blog_id     = VideoPressToken::blog_id();
832            $token       = VideoPressToken::videopress_onetime_upload_token();
833            $video_guid  = $request->get_param( 'video_guid' );
834            $json_params = $request->get_json_params();
835
836            $args = array(
837                'method'  => 'POST',
838                'headers' => array(
839                    'content-type'  => 'application/json',
840                    'Authorization' => 'X_UPLOAD_TOKEN token="' . $token . '" blog_id="' . $blog_id . '"',
841                ),
842                // The WordPress.com poster update fetches and processes the image, which routinely exceeds WordPress's 5-second default timeout.
843                'timeout' => 30,
844            );
845
846            return $this->wpcom_poster_request(
847                $video_guid,
848                $args,
849                wp_json_encode( $json_params, JSON_UNESCAPED_SLASHES )
850            );
851        } catch ( \Exception $e ) {
852            return rest_ensure_response( new WP_Error( 'videopress_block_update_poster_error', $e->getMessage() ) );
853        }
854    }
855
856    /**
857     * Retrieves a poster image via the WPCOM REST API.
858     *
859     * @param WP_REST_Request $request the request object.
860     * @return object|WP_Error Success object or WP_Error with error details.
861     */
862    public function videopress_block_get_poster( $request ) {
863        $video_guid = $request->get_param( 'video_guid' );
864        $jwt        = VideoPressToken::videopress_playback_jwt( $video_guid );
865
866        // videopress_playback_jwt() returns rather than throws on a transport
867        // failure or a missing blog token, so surface the error instead of
868        // interpolating a WP_Error into the query string below (a fatal on PHP 8).
869        if ( is_wp_error( $jwt ) ) {
870            return rest_ensure_response( $jwt );
871        }
872
873        $args = array(
874            'method' => 'GET',
875        );
876
877        return $this->wpcom_poster_request(
878            $video_guid,
879            $args,
880            null,
881            'metadata_token=' . $jwt
882        );
883    }
884
885    /**
886     * Endpoint for getting the VideoPress Upload JWT
887     *
888     * @return WP_Rest_Response - The response object.
889     */
890    public static function videopress_upload_jwt() {
891        $has_connected_owner = Data::has_connected_owner();
892        if ( ! $has_connected_owner ) {
893            return rest_ensure_response(
894                new WP_Error(
895                    'owner_not_connected',
896                    'User not connected.',
897                    array(
898                        'code'        => 503,
899                        'connect_url' => Admin_UI::get_admin_page_url(),
900                    )
901                )
902            );
903        }
904
905        $blog_id = Data::get_blog_id();
906        if ( ! $blog_id ) {
907            return rest_ensure_response(
908                new WP_Error( 'site_not_registered', 'Site not registered.', 503 )
909            );
910        }
911
912        try {
913            $token  = VideoPressToken::videopress_upload_jwt();
914            $status = 200;
915            $data   = array(
916                'upload_token'   => $token,
917                'upload_url'     => videopress_make_resumable_upload_path( $blog_id ),
918                'upload_blog_id' => $blog_id,
919            );
920        } catch ( \Exception $e ) {
921            $status = 500;
922            $data   = array(
923                'error' => $e->getMessage(),
924            );
925
926        }
927
928        return rest_ensure_response(
929            new WP_REST_Response( $data, $status )
930        );
931    }
932
933    /**
934     * Endpoint for generating a VideoPress Playback JWT
935     *
936     * @param WP_REST_Request $request the request object.
937     * @return WP_Rest_Response - The response object.
938     */
939    public static function videopress_playback_jwt( $request ) {
940        $has_connected_owner = Data::has_connected_owner();
941        if ( ! $has_connected_owner ) {
942            return rest_ensure_response(
943                new WP_Error(
944                    'owner_not_connected',
945                    'User not connected.',
946                    array(
947                        'code'        => 503,
948                        'connect_url' => Admin_UI::get_admin_page_url(),
949                    )
950                )
951            );
952        }
953
954        $blog_id = Data::get_blog_id();
955        if ( ! $blog_id ) {
956            return rest_ensure_response(
957                new WP_Error( 'site_not_registered', 'Site not registered.', 503 )
958            );
959        }
960
961        try {
962            $video_guid = $request->get_param( 'video_guid' );
963
964            // Authorize the caller for this specific video before minting a token.
965            // The route only requires `read`, so without this a subscriber could
966            // obtain a playback token for any guid on the site (private or
967            // paywalled) by calling this endpoint directly, bypassing the
968            // front-end player's per-video access check. post_id/subscription_plan_id
969            // carry the embedding context the same way the AJAX player does.
970            $embedded_post_id = (int) $request->get_param( 'post_id' );
971            $selected_plan_id = (int) $request->get_param( 'subscription_plan_id' );
972            if ( ! Access_Control::instance()->is_current_user_authed_for_video( $video_guid, $embedded_post_id, $selected_plan_id ) ) {
973                return rest_ensure_response(
974                    new WP_Error( 'unauthorized', __( 'You cannot view this video.', 'jetpack-videopress-pkg' ), array( 'status' => 403 ) )
975                );
976            }
977
978            $token  = VideoPressToken::videopress_playback_jwt( $video_guid );
979            $status = 200;
980            $data   = array(
981                'playback_token' => $token,
982            );
983        } catch ( \Exception $e ) {
984            $status = 500;
985            $data   = array(
986                'error' => $e->getMessage(),
987            );
988
989        }
990
991        return rest_ensure_response(
992            new WP_REST_Response( $data, $status )
993        );
994    }
995
996    /**
997     * Updates attachment meta and video metadata via the WPCOM REST API.
998     *
999     * @param WP_REST_Request $request the request object.
1000     * @return object|WP_Error Success object or WP_Error with error details.
1001     */
1002    public function videopress_block_update_meta( $request ) {
1003        $json_params = $request->get_json_params();
1004        $post_id     = $json_params['id'];
1005
1006        // The site setting overrides every video, so drop attempts to turn sharing on and keep the stored value.
1007        if ( ! empty( $json_params['display_embed'] ) && Data::get_videopress_share_menu_disabled() ) {
1008            unset( $json_params['display_embed'] );
1009        }
1010
1011        if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
1012            $guid = get_post_meta( $post_id, 'videopress_guid', true );
1013        } else {
1014            $blog_id = get_current_blog_id();
1015            $info    = video_get_info_by_blogpostid( $blog_id, $post_id );
1016            $guid    = $info ? $info->guid : '';
1017        }
1018
1019        if ( ! $guid ) {
1020            return rest_ensure_response(
1021                new WP_Error(
1022                    'error',
1023                    __( 'This attachment cannot be updated yet.', 'jetpack-videopress-pkg' )
1024                )
1025            );
1026        }
1027
1028        $video_request_params = $json_params;
1029        unset( $video_request_params['id'] );
1030        $video_request_params['guid'] = $guid;
1031
1032        $endpoint = 'videos';
1033        $args     = array(
1034            'method'  => 'POST',
1035            'headers' => array( 'content-type' => 'application/json' ),
1036        );
1037
1038        $result = Client::wpcom_json_api_request_as_blog(
1039            $endpoint,
1040            '2',
1041            $args,
1042            wp_json_encode( $video_request_params, JSON_UNESCAPED_SLASHES ),
1043            'wpcom'
1044        );
1045
1046        if ( is_wp_error( $result ) ) {
1047            return rest_ensure_response( $result );
1048        }
1049
1050        $response_body = json_decode( wp_remote_retrieve_body( $result ) );
1051        if ( is_bool( $response_body ) && $response_body ) {
1052            /*
1053             * Title, description and caption of the video are not stored as metadata on the attachment,
1054             * but as post_content, post_title and post_excerpt on the attachment's post object.
1055             * We need to update those fields here, too.
1056             */
1057            $post_title = null;
1058            if ( isset( $json_params['title'] ) ) {
1059                $post_title = sanitize_text_field( $json_params['title'] );
1060                wp_update_post(
1061                    array(
1062                        'ID'         => $post_id,
1063                        'post_title' => $post_title,
1064                    )
1065                );
1066            }
1067
1068            $post_content = null;
1069            if ( isset( $json_params['description'] ) ) {
1070                $post_content = sanitize_textarea_field( $json_params['description'] );
1071                wp_update_post(
1072                    array(
1073                        'ID'           => $post_id,
1074                        'post_content' => $post_content,
1075                    )
1076                );
1077            }
1078
1079            $post_excerpt = null;
1080            if ( isset( $json_params['caption'] ) ) {
1081                $post_excerpt = sanitize_textarea_field( $json_params['caption'] );
1082                wp_update_post(
1083                    array(
1084                        'ID'           => $post_id,
1085                        'post_excerpt' => $post_excerpt,
1086                    )
1087                );
1088            }
1089
1090            // VideoPress data is stored in attachment meta for Jetpack sites, but not on wpcom.
1091            if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
1092                $meta               = wp_get_attachment_metadata( $post_id );
1093                $should_update_meta = false;
1094
1095                if ( ! $meta ) {
1096                    return rest_ensure_response(
1097                        new WP_Error(
1098                            'error',
1099                            __( 'Attachment meta was not found.', 'jetpack-videopress-pkg' )
1100                        )
1101                    );
1102                }
1103
1104                if ( isset( $json_params['display_embed'] ) && isset( $meta['videopress']['display_embed'] ) ) {
1105                    $meta['videopress']['display_embed'] = $json_params['display_embed'];
1106                    $should_update_meta                  = true;
1107                }
1108
1109                if ( isset( $json_params['rating'] ) && isset( $meta['videopress']['rating'] ) && videopress_is_valid_video_rating( $json_params['rating'] ) ) {
1110                    $meta['videopress']['rating'] = $json_params['rating'];
1111                    $should_update_meta           = true;
1112
1113                    /** Set a new meta field so we can filter using it directly */
1114                    update_post_meta( $post_id, 'videopress_rating', $json_params['rating'] );
1115                }
1116
1117                if ( isset( $json_params['title'] ) ) {
1118                    $meta['videopress']['title'] = $post_title;
1119                    $should_update_meta          = true;
1120                }
1121
1122                if ( isset( $json_params['description'] ) ) {
1123                    $meta['videopress']['description'] = $post_content;
1124                    $should_update_meta                = true;
1125                }
1126
1127                if ( isset( $json_params['caption'] ) ) {
1128                    $meta['videopress']['caption'] = $post_excerpt;
1129                    $should_update_meta            = true;
1130                }
1131
1132                if ( isset( $json_params['poster'] ) ) {
1133                    $meta['videopress']['poster'] = $json_params['poster'];
1134                    $should_update_meta           = true;
1135                }
1136
1137                if ( isset( $json_params['allow_download'] ) ) {
1138                    $allow_download = (bool) $json_params['allow_download'];
1139                    if ( ! isset( $meta['videopress']['allow_download'] ) || $meta['videopress']['allow_download'] !== $allow_download ) {
1140                        $meta['videopress']['allow_download'] = $allow_download;
1141                        $should_update_meta                   = true;
1142                    }
1143                }
1144
1145                if ( isset( $json_params['privacy_setting'] ) ) {
1146                    $privacy_setting = $json_params['privacy_setting'];
1147                    if ( ! isset( $meta['videopress']['privacy_setting'] ) || $meta['videopress']['privacy_setting'] !== $privacy_setting ) {
1148                        $meta['videopress']['privacy_setting'] = $privacy_setting;
1149                        $should_update_meta                    = true;
1150
1151                        /** Set a new meta field so we can filter using it directly */
1152                        update_post_meta( $post_id, 'videopress_privacy_setting', $privacy_setting );
1153                    }
1154                }
1155
1156                if ( $should_update_meta ) {
1157                    wp_update_attachment_metadata( $post_id, $meta );
1158                }
1159            }
1160
1161            return rest_ensure_response(
1162                array(
1163                    'code'    => 'success',
1164                    'message' => __( 'Video meta updated successfully.', 'jetpack-videopress-pkg' ),
1165                    'data'    => 200,
1166                )
1167            );
1168        } else {
1169            return rest_ensure_response(
1170                new WP_Error(
1171                    $response_body->code,
1172                    $response_body->message,
1173                    $response_body->data
1174                )
1175            );
1176        }
1177    }
1178}
1179
1180if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
1181    wpcom_rest_api_v2_load_plugin( 'Automattic\Jetpack\VideoPress\WPCOM_REST_API_V2_Endpoint_VideoPress' );
1182}