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