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