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