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