Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.64% covered (warning)
72.64%
454 / 625
30.00% covered (danger)
30.00%
6 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_VideoPress
72.62% covered (warning)
72.62%
451 / 621
30.00% covered (danger)
30.00%
6 / 20
386.31
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.73% covered (success)
98.73%
233 / 236
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%
71 / 71
100.00% covered (success)
100.00%
1 / 1
16
 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                ),
322                'methods'             => WP_REST_Server::EDITABLE,
323                'callback'            => array( $this, 'videopress_promote_attachment' ),
324                'permission_callback' => function ( $request ) {
325                    // videopress/v1/upload (the self-hosted equivalent) never reaches
326                    // the REST dispatcher on WordPress.com Simple; promotion is the
327                    // Simple path and acts on a caller-supplied attachment id, so in
328                    // addition to upload_files it needs the same per-object check to
329                    // avoid a cross-user IDOR.
330                    if ( ! Data::can_perform_action() || ! current_user_can( 'upload_files' ) ) {
331                        return false;
332                    }
333                    return current_user_can( 'edit_post', (int) $request->get_param( 'attachment_id' ) );
334                },
335            )
336        );
337    }
338
339    /**
340     * Returns the VideoPress site settings.
341     *
342     * `Data::get_videopress_settings()` is already IS_WPCOM-aware (site
343     * privacy / site type resolution), so the same callback serves every
344     * host.
345     *
346     * @return WP_REST_Response The response object.
347     */
348    public function videopress_get_settings() {
349        return rest_ensure_response( Data::get_videopress_settings() );
350    }
351
352    /**
353     * Updates the VideoPress site settings.
354     *
355     * Mirrors `VideoPress_Rest_Api_V1_Settings::update_settings()`, except
356     * on WPCOM `videopress_videos_private_for_site` is not honored:
357     * `videopress_private_enabled_for_site` is a dead option on Simple,
358     * where the site-default privacy derives from the site's own privacy
359     * setting. When a caller supplies that param on WPCOM it is not
360     * persisted, and the response reports it under `ignored` so consumers
361     * are not told a write succeeded when it was silently discarded.
362     *
363     * @param WP_REST_Request $request The request object.
364     * @return WP_REST_Response The response object.
365     */
366    public function videopress_update_settings( $request ) {
367        $private_for_site        = $request->get_param( 'videopress_videos_private_for_site' );
368        $auto_subtitles_disabled = $request->get_param( 'videopress_auto_subtitles_disabled' );
369        $player_preload_disabled = $request->get_param( 'videopress_player_preload_disabled' );
370        $inline_player_enabled   = $request->get_param( 'videopress_inline_player_enabled' );
371        $share_menu_disabled     = $request->get_param( 'videopress_share_menu_disabled' );
372
373        $ignored = array();
374
375        // On WordPress.com Simple the site-default privacy derives from the
376        // site's own privacy setting, so `videopress_private_enabled_for_site`
377        // is a dead option. Drop the param rather than pretend to persist it,
378        // and surface it as ignored so the response stays truthful.
379        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
380            if ( null !== $private_for_site ) {
381                $ignored[] = 'videopress_videos_private_for_site';
382            }
383            $private_for_site = null;
384        }
385
386        if ( null !== $private_for_site ) {
387            update_option( 'videopress_private_enabled_for_site', $private_for_site );
388        }
389
390        if ( null !== $auto_subtitles_disabled ) {
391            update_option( 'videopress_auto_subtitles_disabled', $auto_subtitles_disabled );
392        }
393
394        if ( null !== $player_preload_disabled ) {
395            update_option( 'videopress_player_preload_disabled', $player_preload_disabled );
396        }
397
398        if ( null !== $inline_player_enabled ) {
399            update_option( 'videopress_inline_player_enabled', $inline_player_enabled );
400        }
401
402        if ( null !== $share_menu_disabled ) {
403            update_option( 'videopress_share_menu_disabled', $share_menu_disabled );
404        }
405
406        $response = array(
407            'code'    => 'success',
408            'message' => __( 'VideoPress settings updated successfully.', 'jetpack-videopress-pkg' ),
409            'data'    => 200,
410        );
411
412        if ( ! empty( $ignored ) ) {
413            $response['ignored'] = $ignored;
414            $response['message'] = __( 'VideoPress settings updated. Some settings are not configurable on this site and were ignored.', 'jetpack-videopress-pkg' );
415        }
416
417        return rest_ensure_response( $response );
418    }
419
420    /**
421     * Promote an existing local video attachment to VideoPress. WordPress.com
422     * Simple only.
423     *
424     * The population this serves: sites that uploaded videos on a plan
425     * without VideoPress and later upgraded to one that includes it â€” their
426     * pre-upgrade videos are plain attachments with no path onto VideoPress
427     * (the dashboard's self-hosted promote flow can't run on Simple).
428     *
429     * Promotion is in-place: the same attachment id gains a row in the
430     * global videos table â€” no sibling attachment is created and no
431     * `_videopress_uploaded_id` marker is written (that is the self-hosted
432     * sibling convention). The handler calls the exact primitive every
433     * direct upload to a VideoPress-enabled Simple site flows through via
434     * its `add_attachment` hook: `remote_transcode_one_video()`.
435     *
436     * @param WP_REST_Request $request The request object.
437     * @return WP_REST_Response|WP_Error
438     */
439    public function videopress_promote_attachment( $request ) {
440        if ( ! $this->promote_is_available() ) {
441            return new WP_Error(
442                'videopress_promote_not_available',
443                __( 'Promoting local videos is only available on WordPress.com sites.', 'jetpack-videopress-pkg' ),
444                array( 'status' => 404 )
445            );
446        }
447
448        $attachment_id = (int) $request->get_param( 'attachment_id' );
449        $blog_id       = get_current_blog_id();
450
451        $post = get_post( $attachment_id );
452        if ( ! $post || 'attachment' !== $post->post_type || 'trash' === $post->post_status || ! wp_attachment_is( 'video', $post ) ) {
453            return new WP_Error(
454                'videopress_promote_invalid_attachment',
455                __( 'The attachment is not a video in this site’s media library.', 'jetpack-videopress-pkg' ),
456                array( 'status' => 404 )
457            );
458        }
459
460        /*
461         * Plan gate. The native path enforces VideoPress at upload/mime time
462         * (wpcom_site_can_upload_videos()) and remote_transcode_one_video()
463         * itself checks nothing â€” without this, a site whose plan allows
464         * plain video uploads but not VideoPress could enqueue transcodes.
465         */
466        if ( ! $this->promote_site_has_videopress( $blog_id ) ) {
467            return new WP_Error(
468                'videopress_promote_not_allowed',
469                __( 'This site’s plan does not include VideoPress.', 'jetpack-videopress-pkg' ),
470                array( 'status' => 403 )
471            );
472        }
473
474        if ( ! $this->promote_load_primitives() ) {
475            return new WP_Error(
476                'videopress_promote_unavailable',
477                __( 'VideoPress is not available right now. Please try again later.', 'jetpack-videopress-pkg' ),
478                array( 'status' => 500 )
479            );
480        }
481
482        /*
483         * Already on VideoPress? Report success idempotently. Cache-busted
484         * read: the wpcom delete path does clean this key, but a stale 12h
485         * 'video-info' entry must not misreport here â€” and the fresh read
486         * re-primes the cache the primitive's own (non-busted) lookup uses.
487         */
488        $info = $this->promote_video_info( $blog_id, $attachment_id );
489        if ( $info && ! empty( $info->guid ) ) {
490            return rest_ensure_response(
491                array(
492                    'guid'               => $info->guid,
493                    'media_id'           => $attachment_id,
494                    'already_videopress' => true,
495                )
496            );
497        }
498
499        /*
500         * A soft-deleted VideoPress row may still occupy this attachment's
501         * slot: the videos table's primary key is (blog_id, post_id), so a
502         * tombstoned row makes video_create_info()'s insert fail silently
503         * and the fresh promote below would report an unexplained failure.
504         * (The tombstoned attachment renders as an ordinary local video â€”
505         * the REST fields only see live rows â€” so the UI can reach this.)
506         * Detect it and answer honestly instead. Resurrecting the row via
507         * the primitive's $redo path is a possible follow-up, but it needs
508         * rollback semantics this endpoint doesn't want to own yet.
509         */
510        if ( $this->promote_find_any_guid( $blog_id, $attachment_id ) ) {
511            return new WP_Error(
512                'videopress_promote_previously_deleted',
513                __( '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' ),
514                array( 'status' => 409 )
515            );
516        }
517
518        /*
519         * remote_transcode_one_video() derives the transcoder's fetch URL
520         * from the attached file's blogs.dir path with an unguarded regex; a
521         * non-matching path (some imports/migrations) would still create the
522         * videos row and enqueue a malformed job that renders as
523         * "Processing" forever. Validate with the same pattern first
524         * (verbatim, unescaped dot included) and fail clean.
525         */
526        $path = get_attached_file( $attachment_id );
527        if ( ! $path || ! preg_match( '|/wp-content/blogs.dir\S+?files(.+)$|i', $path ) ) {
528            return new WP_Error(
529                'videopress_promote_unsupported_file',
530                __( 'This video’s file cannot be promoted automatically. Please download it and upload it again.', 'jetpack-videopress-pkg' ),
531                array( 'status' => 400 )
532            );
533        }
534
535        /*
536         * Best-effort mutex around the primitive: the pre-checks above are
537         * check-then-act, and remote_transcode_one_video() ignores
538         * video_create_info()'s outcome and queues its transcode job
539         * unconditionally â€” so two near-simultaneous promotes (double-click,
540         * two tabs) would transcode the same video twice. wp_cache_add() is
541         * atomic on the wpcom object cache; the TTL comfortably outlives the
542         * primitive's sleep(3) and self-heals if the request dies mid-hold.
543         */
544        $promote_lock = $this->promote_lock_key( $blog_id, $attachment_id );
545        if ( ! wp_cache_add( $promote_lock, 1, 'video-info', 30 ) ) {
546            return new WP_Error(
547                'videopress_promote_in_progress',
548                __( 'This video is already being promoted to VideoPress.', 'jetpack-videopress-pkg' ),
549                array( 'status' => 409 )
550            );
551        }
552
553        /*
554         * Creates the videos-table row (video_create_info()) and enqueues
555         * the async transcode job. The fresh-upload path sleep(3)s before
556         * queueing (DB-write settling), so this request takes ~3s.
557         */
558        $this->promote_transcode( $attachment_id );
559
560        /*
561         * The primitive returns bare false for every bail reason (missing
562         * attachment, already transcoded, â€¦), so verify by re-reading the
563         * videos table instead of trusting the return value.
564         */
565        $info = $this->promote_video_info( $blog_id, $attachment_id );
566
567        wp_cache_delete( $promote_lock, 'video-info' );
568
569        if ( ! $info || empty( $info->guid ) ) {
570            return new WP_Error(
571                'videopress_promote_failed',
572                __( 'The video could not be promoted to VideoPress. Please try again later.', 'jetpack-videopress-pkg' ),
573                array( 'status' => 500 )
574            );
575        }
576
577        return rest_ensure_response(
578            array(
579                'guid'     => $info->guid,
580                'media_id' => $attachment_id,
581            )
582        );
583    }
584
585    /*
586     * The five methods below are the promote flow's wpcom seams. They exist
587     * so the orchestration above is unit-testable: monorepo CI can never
588     * define IS_WPCOM, so without them every branch past the host guard
589     * would be dead code under test. A WorDBless test double overrides
590     * exactly these (and nothing else) to exercise the real ordering,
591     * error contract, and mutex behavior.
592     */
593
594    /**
595     * The object-cache key serializing promotes of one attachment.
596     *
597     * @param int $blog_id       The blog id.
598     * @param int $attachment_id The attachment id.
599     * @return string
600     */
601    protected function promote_lock_key( $blog_id, $attachment_id ) {
602        return "videopress-promote-{$blog_id}-{$attachment_id}";
603    }
604
605    /**
606     * Whether the in-process promote flow is available on this host.
607     *
608     * @return bool
609     */
610    protected function promote_is_available() {
611        return defined( 'IS_WPCOM' ) && IS_WPCOM;
612    }
613
614    /**
615     * Whether the site's plan includes VideoPress.
616     *
617     * @param int $blog_id The blog to check.
618     * @return bool
619     */
620    protected function promote_site_has_videopress( $blog_id ) {
621        return function_exists( 'wpcom_site_has_videopress' ) && wpcom_site_has_videopress( $blog_id );
622    }
623
624    /**
625     * Ensure the wpcom transcode primitives are loaded.
626     *
627     * Public-api requests define ADMIN_PLUGINS, so they normally already
628     * are; this mirrors the wpcom TUS uploader's
629     * ensure_wpcom_admin_includes_present() guard for any context where
630     * they aren't. Each file is existence-checked individually so a
631     * partially-moved set mid-deploy degrades to the handler's clean error
632     * rather than a require fatal.
633     *
634     * @return bool Whether the primitives are callable.
635     */
636    protected function promote_load_primitives() {
637        if ( ! function_exists( 'remote_transcode_one_video' ) && defined( 'ABSPATH' ) ) {
638            $transcode_includes = array(
639                'class.videopress-job-base.php',
640                'class.video-job-thumbnails.php',
641                'class.video-thumbnailer.php',
642                'video-transcoder.php',
643                'transcode.php',
644            );
645            foreach ( $transcode_includes as $transcode_include ) {
646                $transcode_include_path = ABSPATH . 'wp-content/admin-plugins/videopress/' . $transcode_include;
647                if ( file_exists( $transcode_include_path ) ) {
648                    require_once $transcode_include_path;
649                }
650            }
651        }
652
653        return function_exists( 'remote_transcode_one_video' ) && function_exists( 'video_get_info_by_blogpostid' );
654    }
655
656    /**
657     * Cache-busted read of the live videos-table row for an attachment.
658     *
659     * @param int $blog_id       The blog id.
660     * @param int $attachment_id The attachment id.
661     * @return object|false The video info object, or false when no live row exists.
662     */
663    protected function promote_video_info( $blog_id, $attachment_id ) {
664        return video_get_info_by_blogpostid( $blog_id, $attachment_id, true );
665    }
666
667    /**
668     * Find any videos-table guid for the attachment, tombstoned included â€”
669     * the live-row helper can't see soft-deleted rows.
670     *
671     * @param int $blog_id       The blog id.
672     * @param int $attachment_id The attachment id.
673     * @return string|null The guid, or null when no row exists at all.
674     */
675    protected function promote_find_any_guid( $blog_id, $attachment_id ) {
676        global $wpdb;
677        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- wpcom global table; must see tombstoned rows and must not be cached.
678        return $wpdb->get_var( $wpdb->prepare( 'SELECT guid FROM videos WHERE blog_id = %d AND post_id = %d', $blog_id, $attachment_id ) );
679    }
680
681    /**
682     * Run the wpcom promote primitive for an attachment.
683     *
684     * @param int $attachment_id The attachment id.
685     * @return void
686     */
687    protected function promote_transcode( $attachment_id ) {
688        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.
689    }
690
691    /**
692     * Check whether the video belongs to the current site,
693     * considering the given post_id and the video_guid.
694     *
695     * @param WP_REST_Request $request The request object.
696     * @return WP_REST_Response True if the video belongs to the current site, false otherwise.
697     */
698    public function videopress_video_belong_to_site( $request ) {
699        $post_id    = $request->get_param( 'post_id' );
700        $video_guid = $request->get_param( 'video_guid' );
701
702        if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
703            $found_guid = get_post_meta( $post_id, 'videopress_guid', true );
704        } else {
705            $blog_id    = get_current_blog_id();
706            $info       = video_get_info_by_blogpostid( $blog_id, $post_id );
707            $found_guid = $info ? $info->guid : '';
708        }
709
710        if ( ! $found_guid ) {
711            return rest_ensure_response( array( 'video-belong-to-site' => false ) );
712        }
713
714        return rest_ensure_response( array( 'video-belong-to-site' => $found_guid === $video_guid ) );
715    }
716
717    /**
718     * Hit WPCOM poster endpoint.
719     *
720     * @param string $video_guid  The VideoPress GUID.
721     * @param array  $args        Request args.
722     * @param array  $body        Request body.
723     * @param string $query       Request query.
724     * @return WP_REST_Response|WP_Error
725     */
726    public function wpcom_poster_request( $video_guid, $args, $body = null, $query = '' ) {
727        $query    = $query !== '' ? '?' . $query : '';
728        $endpoint = 'videos/' . $video_guid . '/poster' . $query;
729
730        $url = sprintf(
731            '%s/%s/v%s/%s',
732            Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' ),
733            'rest',
734            '1.1',
735            $endpoint
736        );
737
738        $request_args = array_merge( $args, array( 'body' => $body ) );
739
740        // @phan-suppress-next-line PhanAccessMethodInternal -- Phan is correct, but the usage is intentional.
741        $result = Client::_wp_remote_request( $url, $request_args );
742
743        if ( is_wp_error( $result ) ) {
744            return rest_ensure_response( $result );
745        }
746
747        $response = $result['http_response'];
748
749        $status = $response->get_status();
750
751        $data = array(
752            'code' => $status,
753            'data' => json_decode( $response->get_data(), true ),
754        );
755
756        return rest_ensure_response(
757            new WP_REST_Response( $data, $status )
758        );
759    }
760
761    /**
762     * Resolve a VideoPress guid to its local attachment id on the current site.
763     *
764     * Returns 0 when the GUID does not belong to the current site.
765     *
766     * @internal
767     *
768     * @param string $video_guid The VideoPress GUID.
769     * @return int The attachment/post id, or 0 if it cannot be resolved.
770     */
771    public static function get_video_attachment_id( $video_guid ) {
772        if ( empty( $video_guid ) ) {
773            return 0;
774        }
775
776        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
777            $video_info = video_get_info_by_guid( $video_guid );
778            if ( empty( $video_info ) || (int) $video_info->blog_id !== get_current_blog_id() ) {
779                return 0;
780            }
781            return (int) $video_info->post_id;
782        }
783
784        // utility-functions.php is not loaded in every configuration, so fail
785        // closed rather than fatal if the resolver is unavailable.
786        if ( ! function_exists( 'videopress_get_post_by_guid' ) ) {
787            return 0;
788        }
789
790        $attachment = videopress_get_post_by_guid( $video_guid );
791        return $attachment ? (int) $attachment->ID : 0;
792    }
793
794    /**
795     * Update the a poster image via the WPCOM REST API.
796     *
797     * @param WP_REST_Request $request The request object.
798     * @return WP_REST_Response|WP_Error
799     */
800    public function videopress_block_update_poster( $request ) {
801        try {
802            $blog_id     = VideoPressToken::blog_id();
803            $token       = VideoPressToken::videopress_onetime_upload_token();
804            $video_guid  = $request->get_param( 'video_guid' );
805            $json_params = $request->get_json_params();
806
807            $args = array(
808                'method'  => 'POST',
809                'headers' => array(
810                    'content-type'  => 'application/json',
811                    'Authorization' => 'X_UPLOAD_TOKEN token="' . $token . '" blog_id="' . $blog_id . '"',
812                ),
813                // The WordPress.com poster update fetches and processes the image, which routinely exceeds WordPress's 5-second default timeout.
814                'timeout' => 30,
815            );
816
817            return $this->wpcom_poster_request(
818                $video_guid,
819                $args,
820                wp_json_encode( $json_params, JSON_UNESCAPED_SLASHES )
821            );
822        } catch ( \Exception $e ) {
823            return rest_ensure_response( new WP_Error( 'videopress_block_update_poster_error', $e->getMessage() ) );
824        }
825    }
826
827    /**
828     * Retrieves a poster image via the WPCOM REST API.
829     *
830     * @param WP_REST_Request $request the request object.
831     * @return object|WP_Error Success object or WP_Error with error details.
832     */
833    public function videopress_block_get_poster( $request ) {
834        $video_guid = $request->get_param( 'video_guid' );
835        $jwt        = VideoPressToken::videopress_playback_jwt( $video_guid );
836
837        // videopress_playback_jwt() returns rather than throws on a transport
838        // failure or a missing blog token, so surface the error instead of
839        // interpolating a WP_Error into the query string below (a fatal on PHP 8).
840        if ( is_wp_error( $jwt ) ) {
841            return rest_ensure_response( $jwt );
842        }
843
844        $args = array(
845            'method' => 'GET',
846        );
847
848        return $this->wpcom_poster_request(
849            $video_guid,
850            $args,
851            null,
852            'metadata_token=' . $jwt
853        );
854    }
855
856    /**
857     * Endpoint for getting the VideoPress Upload JWT
858     *
859     * @return WP_Rest_Response - The response object.
860     */
861    public static function videopress_upload_jwt() {
862        $has_connected_owner = Data::has_connected_owner();
863        if ( ! $has_connected_owner ) {
864            return rest_ensure_response(
865                new WP_Error(
866                    'owner_not_connected',
867                    'User not connected.',
868                    array(
869                        'code'        => 503,
870                        'connect_url' => Admin_UI::get_admin_page_url(),
871                    )
872                )
873            );
874        }
875
876        $blog_id = Data::get_blog_id();
877        if ( ! $blog_id ) {
878            return rest_ensure_response(
879                new WP_Error( 'site_not_registered', 'Site not registered.', 503 )
880            );
881        }
882
883        try {
884            $token  = VideoPressToken::videopress_upload_jwt();
885            $status = 200;
886            $data   = array(
887                'upload_token'   => $token,
888                'upload_url'     => videopress_make_resumable_upload_path( $blog_id ),
889                'upload_blog_id' => $blog_id,
890            );
891        } catch ( \Exception $e ) {
892            $status = 500;
893            $data   = array(
894                'error' => $e->getMessage(),
895            );
896
897        }
898
899        return rest_ensure_response(
900            new WP_REST_Response( $data, $status )
901        );
902    }
903
904    /**
905     * Endpoint for generating a VideoPress Playback JWT
906     *
907     * @param WP_REST_Request $request the request object.
908     * @return WP_Rest_Response - The response object.
909     */
910    public static function videopress_playback_jwt( $request ) {
911        $has_connected_owner = Data::has_connected_owner();
912        if ( ! $has_connected_owner ) {
913            return rest_ensure_response(
914                new WP_Error(
915                    'owner_not_connected',
916                    'User not connected.',
917                    array(
918                        'code'        => 503,
919                        'connect_url' => Admin_UI::get_admin_page_url(),
920                    )
921                )
922            );
923        }
924
925        $blog_id = Data::get_blog_id();
926        if ( ! $blog_id ) {
927            return rest_ensure_response(
928                new WP_Error( 'site_not_registered', 'Site not registered.', 503 )
929            );
930        }
931
932        try {
933            $video_guid = $request->get_param( 'video_guid' );
934
935            // Authorize the caller for this specific video before minting a token.
936            // The route only requires `read`, so without this a subscriber could
937            // obtain a playback token for any guid on the site (private or
938            // paywalled) by calling this endpoint directly, bypassing the
939            // front-end player's per-video access check. post_id/subscription_plan_id
940            // carry the embedding context the same way the AJAX player does.
941            $embedded_post_id = (int) $request->get_param( 'post_id' );
942            $selected_plan_id = (int) $request->get_param( 'subscription_plan_id' );
943            if ( ! Access_Control::instance()->is_current_user_authed_for_video( $video_guid, $embedded_post_id, $selected_plan_id ) ) {
944                return rest_ensure_response(
945                    new WP_Error( 'unauthorized', __( 'You cannot view this video.', 'jetpack-videopress-pkg' ), array( 'status' => 403 ) )
946                );
947            }
948
949            $token  = VideoPressToken::videopress_playback_jwt( $video_guid );
950            $status = 200;
951            $data   = array(
952                'playback_token' => $token,
953            );
954        } catch ( \Exception $e ) {
955            $status = 500;
956            $data   = array(
957                'error' => $e->getMessage(),
958            );
959
960        }
961
962        return rest_ensure_response(
963            new WP_REST_Response( $data, $status )
964        );
965    }
966
967    /**
968     * Updates attachment meta and video metadata via the WPCOM REST API.
969     *
970     * @param WP_REST_Request $request the request object.
971     * @return object|WP_Error Success object or WP_Error with error details.
972     */
973    public function videopress_block_update_meta( $request ) {
974        $json_params = $request->get_json_params();
975        $post_id     = $json_params['id'];
976
977        // The site setting overrides every video, so drop attempts to turn sharing on and keep the stored value.
978        if ( ! empty( $json_params['display_embed'] ) && Data::get_videopress_share_menu_disabled() ) {
979            unset( $json_params['display_embed'] );
980        }
981
982        if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
983            $guid = get_post_meta( $post_id, 'videopress_guid', true );
984        } else {
985            $blog_id = get_current_blog_id();
986            $info    = video_get_info_by_blogpostid( $blog_id, $post_id );
987            $guid    = $info ? $info->guid : '';
988        }
989
990        if ( ! $guid ) {
991            return rest_ensure_response(
992                new WP_Error(
993                    'error',
994                    __( 'This attachment cannot be updated yet.', 'jetpack-videopress-pkg' )
995                )
996            );
997        }
998
999        $video_request_params = $json_params;
1000        unset( $video_request_params['id'] );
1001        $video_request_params['guid'] = $guid;
1002
1003        $endpoint = 'videos';
1004        $args     = array(
1005            'method'  => 'POST',
1006            'headers' => array( 'content-type' => 'application/json' ),
1007        );
1008
1009        $result = Client::wpcom_json_api_request_as_blog(
1010            $endpoint,
1011            '2',
1012            $args,
1013            wp_json_encode( $video_request_params, JSON_UNESCAPED_SLASHES ),
1014            'wpcom'
1015        );
1016
1017        if ( is_wp_error( $result ) ) {
1018            return rest_ensure_response( $result );
1019        }
1020
1021        $response_body = json_decode( wp_remote_retrieve_body( $result ) );
1022        if ( is_bool( $response_body ) && $response_body ) {
1023            /*
1024             * Title, description and caption of the video are not stored as metadata on the attachment,
1025             * but as post_content, post_title and post_excerpt on the attachment's post object.
1026             * We need to update those fields here, too.
1027             */
1028            $post_title = null;
1029            if ( isset( $json_params['title'] ) ) {
1030                $post_title = sanitize_text_field( $json_params['title'] );
1031                wp_update_post(
1032                    array(
1033                        'ID'         => $post_id,
1034                        'post_title' => $post_title,
1035                    )
1036                );
1037            }
1038
1039            $post_content = null;
1040            if ( isset( $json_params['description'] ) ) {
1041                $post_content = sanitize_textarea_field( $json_params['description'] );
1042                wp_update_post(
1043                    array(
1044                        'ID'           => $post_id,
1045                        'post_content' => $post_content,
1046                    )
1047                );
1048            }
1049
1050            $post_excerpt = null;
1051            if ( isset( $json_params['caption'] ) ) {
1052                $post_excerpt = sanitize_textarea_field( $json_params['caption'] );
1053                wp_update_post(
1054                    array(
1055                        'ID'           => $post_id,
1056                        'post_excerpt' => $post_excerpt,
1057                    )
1058                );
1059            }
1060
1061            // VideoPress data is stored in attachment meta for Jetpack sites, but not on wpcom.
1062            if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
1063                $meta               = wp_get_attachment_metadata( $post_id );
1064                $should_update_meta = false;
1065
1066                if ( ! $meta ) {
1067                    return rest_ensure_response(
1068                        new WP_Error(
1069                            'error',
1070                            __( 'Attachment meta was not found.', 'jetpack-videopress-pkg' )
1071                        )
1072                    );
1073                }
1074
1075                if ( isset( $json_params['display_embed'] ) && isset( $meta['videopress']['display_embed'] ) ) {
1076                    $meta['videopress']['display_embed'] = $json_params['display_embed'];
1077                    $should_update_meta                  = true;
1078                }
1079
1080                if ( isset( $json_params['rating'] ) && isset( $meta['videopress']['rating'] ) && videopress_is_valid_video_rating( $json_params['rating'] ) ) {
1081                    $meta['videopress']['rating'] = $json_params['rating'];
1082                    $should_update_meta           = true;
1083
1084                    /** Set a new meta field so we can filter using it directly */
1085                    update_post_meta( $post_id, 'videopress_rating', $json_params['rating'] );
1086                }
1087
1088                if ( isset( $json_params['title'] ) ) {
1089                    $meta['videopress']['title'] = $post_title;
1090                    $should_update_meta          = true;
1091                }
1092
1093                if ( isset( $json_params['description'] ) ) {
1094                    $meta['videopress']['description'] = $post_content;
1095                    $should_update_meta                = true;
1096                }
1097
1098                if ( isset( $json_params['caption'] ) ) {
1099                    $meta['videopress']['caption'] = $post_excerpt;
1100                    $should_update_meta            = true;
1101                }
1102
1103                if ( isset( $json_params['poster'] ) ) {
1104                    $meta['videopress']['poster'] = $json_params['poster'];
1105                    $should_update_meta           = true;
1106                }
1107
1108                if ( isset( $json_params['allow_download'] ) ) {
1109                    $allow_download = (bool) $json_params['allow_download'];
1110                    if ( ! isset( $meta['videopress']['allow_download'] ) || $meta['videopress']['allow_download'] !== $allow_download ) {
1111                        $meta['videopress']['allow_download'] = $allow_download;
1112                        $should_update_meta                   = true;
1113                    }
1114                }
1115
1116                if ( isset( $json_params['privacy_setting'] ) ) {
1117                    $privacy_setting = $json_params['privacy_setting'];
1118                    if ( ! isset( $meta['videopress']['privacy_setting'] ) || $meta['videopress']['privacy_setting'] !== $privacy_setting ) {
1119                        $meta['videopress']['privacy_setting'] = $privacy_setting;
1120                        $should_update_meta                    = true;
1121
1122                        /** Set a new meta field so we can filter using it directly */
1123                        update_post_meta( $post_id, 'videopress_privacy_setting', $privacy_setting );
1124                    }
1125                }
1126
1127                if ( $should_update_meta ) {
1128                    wp_update_attachment_metadata( $post_id, $meta );
1129                }
1130            }
1131
1132            return rest_ensure_response(
1133                array(
1134                    'code'    => 'success',
1135                    'message' => __( 'Video meta updated successfully.', 'jetpack-videopress-pkg' ),
1136                    'data'    => 200,
1137                )
1138            );
1139        } else {
1140            return rest_ensure_response(
1141                new WP_Error(
1142                    $response_body->code,
1143                    $response_body->message,
1144                    $response_body->data
1145                )
1146            );
1147        }
1148    }
1149}
1150
1151if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
1152    wpcom_rest_api_v2_load_plugin( 'Automattic\Jetpack\VideoPress\WPCOM_REST_API_V2_Endpoint_VideoPress' );
1153}