Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.09% covered (warning)
85.09%
194 / 228
50.00% covered (danger)
50.00%
12 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Access_Control
85.09% covered (warning)
85.09%
194 / 228
50.00% covered (danger)
50.00%
12 / 24
167.75
0.00% covered (danger)
0.00%
0 / 1
 set_guid_subscription
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 get_subscription_plan_id
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 instance
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 jetpack_memberships_available
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 jetpack_subscriptions_available
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
9.96
 get_default_user_capability_for_post
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 build_restriction_details
69.57% covered (warning)
69.57%
16 / 23
0.00% covered (danger)
0.00%
0 / 1
12.82
 check_block_level_access
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 block_gate_grants_access
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
8.14
 get_subscriber_only_restriction_details
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 filter_video_restriction_details
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 default_video_restriction_details
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 build_and_cache_post_guids
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
5.01
 ensure_post_guids_cached
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 collect_guids_from_content
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
10
 collect_guids_from_blocks
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
24
 get_required_plan_ids_for_guid
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 find_gating_plan_ids
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
9.06
 container_gates_guid
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 extract_plan_ids
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 post_embeds_videopress_guid_cached
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 is_current_user_authed_for_video
79.41% covered (warning)
79.41%
27 / 34
0.00% covered (danger)
0.00%
0 / 1
16.96
 filter_is_current_user_authed_for_video
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_videopress_blog_id
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * VideoPress Access Control.
4 *
5 * @package automattic/jetpack-videopress
6 */
7
8namespace Automattic\Jetpack\VideoPress;
9
10use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Abstract_Token_Subscription_Service;
11use Automattic\Jetpack\Modules;
12use VIDEOPRESS_PRIVACY;
13use WP_Post;
14
15/**
16 * VideoPress video access control utilities.
17 *
18 * Note: this is also being used on WordPress.com.
19 * Use IS_WPCOM checks for functionality that is specific to WPCOM/Jetpack.
20 */
21class Access_Control {
22
23    /**
24     * Singleton Access_Control instance.
25     *
26     * @var Access_Control
27     **/
28    private static $instance = null;
29
30    /**
31     * Guid to subscription plan, store, for when used inline on a page.
32     *
33     * @var array
34     */
35    private $guids_to_subscriptions = array();
36
37    /**
38     * Set that this guid is controlled by a subscription.
39     *
40     * @param string     $guid            The guid to set.
41     * @param string|int $subscription_id The subscription to set.
42     *
43     * @return Access_Control
44     */
45    public function set_guid_subscription( $guid, $subscription_id ) {
46        $this->guids_to_subscriptions[ $guid ] = $subscription_id;
47        return $this;
48    }
49
50    /**
51     * Get the subscription for a guid.
52     *
53     * @param string $guid The guid to get.
54     *
55     * @return string|int|false
56     */
57    public function get_subscription_plan_id( $guid ) {
58        return $this->guids_to_subscriptions[ $guid ] ?? false;
59    }
60
61    /**
62     * Get the singleton instance.
63     *
64     * @return self
65     */
66    public static function instance() {
67        if ( null === self::$instance ) {
68            self::$instance = new self();
69        }
70
71        return self::$instance;
72    }
73
74    /**
75     * Determines if Jetpack Memberships are available.
76     *
77     * @return bool
78     */
79    private function jetpack_memberships_available() {
80        return class_exists( '\Jetpack_Memberships' );
81    }
82
83    /**
84     * Determines if Jetpack Subscriptions are available.
85     *
86     * @return bool
87     */
88    private function jetpack_subscriptions_available() {
89        $is_module_active = ( new Modules() )->is_active( 'subscriptions' );
90        if ( ! $is_module_active ) {
91            return false;
92        }
93
94        if ( function_exists( '\Automattic\Jetpack\Extensions\Premium_Content\subscription_service' ) ) {
95            return true;
96        }
97
98        if ( ! defined( 'JETPACK__PLUGIN_DIR' ) ) {
99            return false;
100        }
101
102        $subscription_service_file_path = JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
103        if ( ! file_exists( $subscription_service_file_path ) ) {
104            return false;
105        }
106
107        require_once $subscription_service_file_path;
108
109        return function_exists( '\Automattic\Jetpack\Extensions\Premium_Content\subscription_service' );
110    }
111
112    /**
113     * Check default user access. By default, subscribers or higher can view videos.
114     *
115     * @param WP_Post $post_to_check The post to check.
116     *
117     * @return bool
118     **/
119    private function get_default_user_capability_for_post( $post_to_check ) {
120        if ( ! isset( $post_to_check->ID ) ) {
121            return false;
122        }
123
124        $default_auth = current_user_can( 'read_post', $post_to_check->ID );
125
126        return $default_auth;
127    }
128
129    /**
130     * Determines if the current user can access restricted content and builds the restriction_details array.
131     *
132     * @param string $guid the video guid.
133     * @param int    $embedded_post_id the post id.
134     * @param int    $selected_plan_id the selected plan id if applicable.
135     *
136     * @return array
137     */
138    private function build_restriction_details( $guid, $embedded_post_id, $selected_plan_id ) {
139        // A missing post ID does not fall back to the global post, as get_post( 0 ) would.
140        $post_to_check = $embedded_post_id > 0 ? get_post( $embedded_post_id ) : null;
141
142        // Only a published embedding post can authorize playback.
143        if ( ! $post_to_check instanceof WP_Post || 'publish' !== $post_to_check->post_status ) {
144            $restriction_details = $this->default_video_restriction_details( false );
145            return $this->filter_video_restriction_details( $restriction_details, $guid, $embedded_post_id, $selected_plan_id );
146        }
147
148        $default_auth        = $this->get_default_user_capability_for_post( $post_to_check );
149        $restriction_details = $this->default_video_restriction_details( $default_auth );
150        // Logged-out visitors never have read_post, so a public post admits them on its status alone.
151        // Neither check knows about post passwords, which the page enforces for everyone.
152        $post_admits_visitor = ( $default_auth || is_post_publicly_viewable( $post_to_check ) )
153            && ! post_password_required( $post_to_check );
154
155        if ( $this->jetpack_memberships_available() ) {
156            $post_access_level = \Jetpack_Memberships::get_post_access_level( $embedded_post_id );
157            if ( 'everybody' !== $post_access_level ) {
158                $memberships_can_view_post         = is_callable( array( '\Jetpack_Memberships', 'user_has_subscription_access' ) )
159                    && \Jetpack_Memberships::user_has_subscription_access( $embedded_post_id );
160                $restriction_details               = $this->get_subscriber_only_restriction_details( $default_auth );
161                $restriction_details['can_access'] = $memberships_can_view_post;
162                $post_admits_visitor               = $post_admits_visitor && $memberships_can_view_post;
163            }
164        }
165
166        return $this->check_block_level_access(
167            $restriction_details,
168            $guid,
169            $embedded_post_id,
170            $selected_plan_id,
171            $post_admits_visitor
172        );
173    }
174
175    /**
176     * Determines if the current user can access restricted block content and updates the restriction_details array.
177     *
178     * @param array  $restriction_details the restriction details array.
179     * @param string $guid the video guid.
180     * @param int    $embedded_post_id the post id.
181     * @param int    $selected_plan_id the plan id sent with the request. Only passed on to the filter below.
182     * @param bool   $post_admits_visitor Whether the embedding post itself, and any paywall on it, lets the visitor in.
183     *
184     * @return array
185     */
186    private function check_block_level_access( $restriction_details, $guid, $embedded_post_id, $selected_plan_id, $post_admits_visitor ) {
187        // Plans come from the stored block; null means no Paid Content block wraps the guid.
188        $required_plan_ids = $this->get_required_plan_ids_for_guid( $embedded_post_id, $guid );
189
190        if ( $this->jetpack_subscriptions_available() && null !== $required_plan_ids ) {
191            $prior_can_access    = $restriction_details['can_access'];
192            $restriction_details = $this->get_subscriber_only_restriction_details( $prior_can_access );
193
194            if ( empty( $required_plan_ids ) ) {
195                $restriction_details['can_access'] = false;
196            } else {
197                $can_view = $this->block_gate_grants_access( $required_plan_ids, $embedded_post_id );
198
199                // A block grant also requires the post-level decision.
200                $restriction_details['can_access'] = $post_admits_visitor && $can_view;
201            }
202        }
203
204        return $this->filter_video_restriction_details(
205            $restriction_details,
206            $guid,
207            $embedded_post_id,
208            $selected_plan_id
209        );
210    }
211
212    /**
213     * Ask the premium-content block's own gate whether the visitor may view content behind these plans.
214     *
215     * Sharing the gate keeps playback and the page in agreement, including on tier upgrades.
216     *
217     * @param int[] $required_plan_ids Plan ids derived from the block wrapping the guid.
218     * @param int   $embedded_post_id  The post the block lives in; there is no loop post to fall back on here.
219     *
220     * @return bool
221     */
222    private function block_gate_grants_access( $required_plan_ids, $embedded_post_id ) {
223        // The gate normally loads with the Paid Content block, which a site can have switched off.
224        if (
225            ! function_exists( '\Automattic\Jetpack\Extensions\Premium_Content\visitor_has_subscription_access_to_plan_ids' )
226            && defined( 'JETPACK__PLUGIN_DIR' )
227        ) {
228            $access_check_path = JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/access-check.php';
229            if ( file_exists( $access_check_path ) ) {
230                require_once $access_check_path;
231            }
232        }
233
234        if ( function_exists( '\Automattic\Jetpack\Extensions\Premium_Content\visitor_has_subscription_access_to_plan_ids' ) ) {
235            return (bool) \Automattic\Jetpack\Extensions\Premium_Content\visitor_has_subscription_access_to_plan_ids( $required_plan_ids, $embedded_post_id );
236        }
237
238        // A newer standalone VideoPress can run beside an older Jetpack that lacks the shared gate.
239        // Exact plan matching is stricter than the gate for tiers, never more permissive.
240        $paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
241
242        // Only paid subscribers should be granted access to the premium content.
243        $access_level = '';
244        if ( class_exists( Abstract_Token_Subscription_Service::class ) ) {
245            $access_level = Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS;
246        }
247
248        // Deny when the service has no entitlement-only check.
249        return is_callable( array( $paywall, 'visitor_has_subscription_access' ) )
250            // @phan-suppress-next-line PhanUndeclaredMethod -- Optional method is checked above to support older services.
251            && $paywall->visitor_has_subscription_access( $required_plan_ids, $access_level, $embedded_post_id );
252    }
253
254    /**
255     * Returns the default restriction_details for a video.
256     *
257     * @param bool $default_can_access The default auth.
258     *
259     * @return array
260     **/
261    private function get_subscriber_only_restriction_details( $default_can_access = false ) {
262        return array(
263            'provider'             => 'jetpack_memberships',
264            'title'                => __( 'This video is subscriber-only', 'jetpack-videopress-pkg' ),
265            'unauthorized_message' => __( 'You need to be subscribed to view this video', 'jetpack-videopress-pkg' ),
266            'can_access'           => $default_can_access,
267        );
268    }
269
270    /**
271     * Filters restriction details.
272     *
273     * @param array  $video_restriction_details The restriction details.
274     * @param string $guid The video guid.
275     * @param int    $embedded_post_id The post id.
276     * @param int    $selected_plan_id The selected plan id if applicable.
277     *
278     * @return array
279     */
280    private function filter_video_restriction_details( $video_restriction_details, $guid, $embedded_post_id, $selected_plan_id ) {
281        /**
282         * Filters the video restriction details.
283         *
284         * @param array  $video_restriction_details The restriction details.
285         * @param string $guid The video guid.
286         * @param int    $embedded_post_id The post id.
287         * @param int    $selected_plan_id The selected plan id if applicable.
288         *
289         * @return array
290         */
291        return (array) apply_filters( 'videopress_video_restriction_details', $video_restriction_details, $guid, $embedded_post_id, $selected_plan_id );
292    }
293
294    /**
295     * Returns the default restriction_details for a video.
296     *
297     * @param bool $default_can_access The default auth.
298     *
299     * @return array
300     **/
301    private function default_video_restriction_details( $default_can_access = false ) {
302        $restriction_details = array(
303            'version'              => '1',
304            'provider'             => 'auth',
305            'title'                => __( 'Unauthorized', 'jetpack-videopress-pkg' ),
306            'unauthorized_message' => __( 'Unauthorized', 'jetpack-videopress-pkg' ),
307            'can_access'           => $default_can_access,
308        );
309
310        return $restriction_details;
311    }
312
313    /**
314     * How long the per-post GUID list stays cached.
315     *
316     * @var int
317     */
318    const GUID_CACHE_EXPIRATION = 12 * HOUR_IN_SECONDS;
319
320    /**
321     * Maximum number of synced-pattern (wp_block) refs resolved per scan.
322     *
323     * @var int
324     */
325    const MAX_PATTERN_REFS = 20;
326
327    /**
328     * Build and cache the list of VideoPress GUIDs present in a post.
329     *
330     * Scans the post content for VideoPress video and playlist blocks, shortcodes, and
331     * URLs, then caches the GUID list in a transient for fast lookup during authorization
332     * checks. Synced pattern (core/block) refs are resolved manually: parse_blocks() does
333     * NOT expand them â€” their content lives in the referenced wp_block post and is only
334     * expanded by WordPress at render time.
335     *
336     * @param int $post_id The post ID to scan.
337     * @return array Array of VideoPress GUIDs found in the post.
338     */
339    public static function build_and_cache_post_guids( $post_id ) {
340        if ( empty( $post_id ) ) {
341            return array();
342        }
343
344        $post_id       = absint( $post_id );
345        $transient_key = "videopress_guids_{$post_id}";
346
347        // Check if already cached.
348        $cached_guids = get_transient( $transient_key );
349        if ( false !== $cached_guids ) {
350            return (array) $cached_guids;
351        }
352
353        $post = get_post( $post_id );
354        if ( ! $post instanceof WP_Post || empty( $post->post_content ) ) {
355            set_transient( $transient_key, array(), self::GUID_CACHE_EXPIRATION );
356            return array();
357        }
358
359        $visited_refs = array();
360        $guids        = self::collect_guids_from_content( $post->post_content, $visited_refs );
361
362        // Cache and return unique GUIDs.
363        $unique_guids = array_values( array_unique( array_filter( $guids ) ) );
364        set_transient( $transient_key, $unique_guids, self::GUID_CACHE_EXPIRATION );
365
366        return $unique_guids;
367    }
368
369    /**
370     * Ensure the given rendered GUIDs are present in a post's cached GUID list.
371     *
372     * Called from block render callbacks, where the block has already been expanded by
373     * WordPress (synced patterns, templates, template parts, widget areas…): the render
374     * itself is proof the video is embedded in the page being served for this post, so
375     * the GUID can be added even when the static content scan cannot see it.
376     *
377     * @param int             $post_id The post ID the block is rendering on.
378     * @param string|string[] $guids   The GUID(s) being rendered.
379     */
380    public static function ensure_post_guids_cached( $post_id, $guids ) {
381        $post_id = absint( $post_id );
382        $guids   = array_filter( (array) $guids, 'is_string' );
383        if ( ! $post_id || ! $guids ) {
384            return;
385        }
386
387        $cached  = self::build_and_cache_post_guids( $post_id );
388        $missing = array_diff( $guids, $cached );
389        if ( $missing ) {
390            set_transient(
391                "videopress_guids_{$post_id}",
392                array_values( array_merge( $cached, $missing ) ),
393                self::GUID_CACHE_EXPIRATION
394            );
395        }
396    }
397
398    /**
399     * Collect VideoPress GUIDs from a chunk of post content: blocks (with synced pattern
400     * refs resolved recursively), VideoPress URLs, and legacy shortcodes.
401     *
402     * @param string $content      The post content to scan.
403     * @param array  $visited_refs Accumulator of wp_block ref ids already resolved, keyed by id.
404     * @return array Array of VideoPress GUIDs found.
405     */
406    private static function collect_guids_from_content( $content, &$visited_refs ) {
407        $guids = self::collect_guids_from_blocks( parse_blocks( $content ), $visited_refs );
408
409        // Scan for VideoPress URLs (oEmbed, core/embed, core/video sources).
410        if ( preg_match_all( '#https?://[^\s"\'<>)]+#i', $content, $matches ) ) {
411            foreach ( $matches[0] as $url ) {
412                $guid = Utils::extract_videopress_guid_from_url( $url );
413                if ( $guid ) {
414                    $guids[] = $guid;
415                }
416            }
417        }
418
419        // Scan for [videopress] and [wpvideo] shortcodes.
420        $pattern = get_shortcode_regex( array( 'videopress', 'wpvideo' ) );
421        $count   = preg_match_all( '/' . $pattern . '/', $content, $matches, PREG_SET_ORDER );
422        if ( false !== $count && $count > 0 ) {
423            foreach ( $matches as $match ) {
424                $atts = shortcode_parse_atts( $match[3] );
425                // Only the positional argument identifies the video; named attributes must not satisfy the binding check.
426                if ( is_array( $atts ) && isset( $atts[0] ) && is_string( $atts[0] ) ) {
427                    $guids[] = $atts[0];
428                }
429            }
430        }
431
432        return $guids;
433    }
434
435    /**
436     * Recursively collect VideoPress GUIDs from parsed blocks.
437     *
438     * Handles videopress/video blocks, videopress/playlist entries, and synced patterns:
439     * a core/block ref is resolved by loading the referenced wp_block post and scanning
440     * its content, mirroring render_block_core_block()'s constraints (published, unlocked
441     * wp_block posts only) with a visited guard against cycles.
442     *
443     * @param array $blocks       Array of parsed blocks.
444     * @param array $visited_refs Accumulator of wp_block ref ids already resolved, keyed by id.
445     * @return array Array of VideoPress GUIDs found.
446     */
447    private static function collect_guids_from_blocks( $blocks, &$visited_refs ) {
448        $guids = array();
449
450        foreach ( $blocks as $block ) {
451            $block_name  = $block['blockName'] ?? null;
452            $attrs       = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
453            $attr_guid   = $attrs['guid'] ?? null;
454            $attr_videos = $attrs['videos'] ?? null;
455            $attr_ref    = $attrs['ref'] ?? null;
456
457            // A VideoPress video block with a GUID.
458            if ( 'videopress/video' === $block_name && is_string( $attr_guid ) ) {
459                $guids[] = $attr_guid;
460            }
461
462            // A VideoPress playlist block: each entry carries its own GUID.
463            if ( 'videopress/playlist' === $block_name && is_array( $attr_videos ) ) {
464                foreach ( $attr_videos as $entry ) {
465                    if ( is_array( $entry ) && isset( $entry['guid'] ) && is_string( $entry['guid'] ) ) {
466                        $guids[] = $entry['guid'];
467                    }
468                }
469            }
470
471            // A synced pattern: resolve the wp_block ref, which parse_blocks() leaves unexpanded.
472            if ( 'core/block' === $block_name && ! empty( $attr_ref ) ) {
473                $ref = absint( $attr_ref );
474                if ( $ref && ! isset( $visited_refs[ $ref ] ) && count( $visited_refs ) < self::MAX_PATTERN_REFS ) {
475                    $visited_refs[ $ref ] = true;
476
477                    $pattern = get_post( $ref );
478                    if (
479                        $pattern instanceof WP_Post
480                        && 'wp_block' === $pattern->post_type
481                        && 'publish' === $pattern->post_status
482                        && empty( $pattern->post_password )
483                        && ! empty( $pattern->post_content )
484                    ) {
485                        $guids = array_merge( $guids, self::collect_guids_from_content( $pattern->post_content, $visited_refs ) );
486                    }
487                }
488            }
489
490            // Recursively check inner blocks.
491            if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
492                $guids = array_merge( $guids, self::collect_guids_from_blocks( $block['innerBlocks'], $visited_refs ) );
493            }
494        }
495
496        return $guids;
497    }
498
499    /**
500     * Read the plan ids gating a video guid from the Paid Content block that wraps it in the stored post.
501     *
502     * Playback is authorized outside any render, so the post's blocks are re-parsed here.
503     *
504     * @param int    $embedded_post_id The post the video is embedded in.
505     * @param string $guid             The video guid.
506     *
507     * @return int[]|null Plan ids that gate the guid, an empty array when it is wrapped in a premium-content
508     *                    block that configures no plan, or null when it is not wrapped in one at all.
509     */
510    private function get_required_plan_ids_for_guid( $embedded_post_id, $guid ) {
511        $embedded_post_id = (int) $embedded_post_id;
512        if ( ! $embedded_post_id ) {
513            return null;
514        }
515
516        $post = get_post( $embedded_post_id );
517        if ( ! $post || false === strpos( (string) $post->post_content, 'wp:premium-content/container' ) ) {
518            return null;
519        }
520
521        return $this->find_gating_plan_ids( parse_blocks( $post->post_content ), $guid );
522    }
523
524    /**
525     * Recursively locate the premium-content/container block that wraps the given guid and return its plan ids.
526     *
527     * @param array  $blocks Parsed blocks (parse_blocks() output or an innerBlocks array).
528     * @param string $guid   The video guid to match.
529     *
530     * @return int[]|null Plan ids of the wrapping premium-content block (possibly an empty array when it
531     *                    configures none), or null when no premium-content block wraps the guid.
532     */
533    private function find_gating_plan_ids( $blocks, $guid ) {
534        foreach ( $blocks as $block ) {
535            if ( empty( $block['blockName'] ) ) {
536                continue;
537            }
538
539            // Descend first so the innermost container, the one directly gating the video, wins.
540            if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
541                $found = $this->find_gating_plan_ids( $block['innerBlocks'], $guid );
542                if ( null !== $found ) {
543                    return $found;
544                }
545            }
546
547            if (
548                is_string( $block['blockName'] ) && 'premium-content/container' === $block['blockName']
549                && $this->container_gates_guid( $block, $guid )
550            ) {
551                return $this->extract_plan_ids( $block['attrs'] ?? array() );
552            }
553        }
554
555        return null;
556    }
557
558    /**
559     * Determine whether a premium-content/container block wraps the given guid.
560     *
561     * Uses the same scan as the per-post GUID cache, so every embed form it recognises counts.
562     *
563     * @param array  $block A parsed premium-content/container block.
564     * @param string $guid  The video guid to match.
565     *
566     * @return bool
567     */
568    private function container_gates_guid( $block, $guid ) {
569        $visited_refs = array();
570
571        return in_array( $guid, self::collect_guids_from_content( serialize_blocks( array( $block ) ), $visited_refs ), true );
572    }
573
574    /**
575     * Normalise a premium-content/container block's plan attributes into a list of plan ids.
576     *
577     * Mirrors the precedence used when rendering the block: the current `selectedPlanIds` array
578     * wins, falling back to the legacy single `selectedPlanId`.
579     *
580     * @param array $attrs The block attributes.
581     *
582     * @return int[] Plan ids, with zero/empty values removed.
583     */
584    private function extract_plan_ids( $attrs ) {
585        if ( isset( $attrs['selectedPlanIds'] ) && is_array( $attrs['selectedPlanIds'] ) ) {
586            return array_values( array_filter( array_map( 'intval', $attrs['selectedPlanIds'] ) ) );
587        }
588
589        if ( isset( $attrs['selectedPlanId'] ) ) {
590            $plan_id = (int) $attrs['selectedPlanId'];
591            return $plan_id ? array( $plan_id ) : array();
592        }
593
594        return array();
595    }
596
597    /**
598     * Check if a post contains a VideoPress GUID, using the cached GUID list for fast lookup.
599     *
600     * Used to prevent the embedded post id â€” which arrives from request input â€” from being
601     * treated as an authorization context when it has no relationship to the requested video.
602     * Matching the attachment id itself is not treated as proof of embedding: attachment ids
603     * are enumerable via the media REST route and would otherwise provide a second path around
604     * this check whenever the attachment has no parent and falls back to the `read` capability.
605     *
606     * @param int    $embedded_post_id The post id to check.
607     * @param string $guid             The video guid to find.
608     *
609     * @return bool
610     */
611    private function post_embeds_videopress_guid_cached( $embedded_post_id, $guid ) {
612        if ( empty( $embedded_post_id ) || empty( $guid ) ) {
613            return false;
614        }
615
616        // Try fast lookup from cache.
617        $transient_key = "videopress_guids_{$embedded_post_id}";
618        $cached_guids  = get_transient( $transient_key );
619
620        if ( false !== $cached_guids ) {
621            return in_array( $guid, (array) $cached_guids, true );
622        }
623
624        // Cache miss: build and cache the GUID list, then check.
625        $guids = self::build_and_cache_post_guids( $embedded_post_id );
626        return in_array( $guid, $guids, true );
627    }
628
629    /**
630     * Determines if the current user can view the provided video. Only ever gets fired if site-wide private videos are enabled.
631     *
632     * Filterable for 3rd party plugins.
633     *
634     * @param string $guid             The video id being checked.
635     * @param int    $embedded_post_id The post id the video is embedded in or 0.
636     * @param int    $selected_plan_id The plan id the earn block this video is embedded in has.
637     */
638    public function is_current_user_authed_for_video( $guid, $embedded_post_id, $selected_plan_id = 0 ) {
639        if ( current_user_can( 'upload_files' ) ) {
640            return $this->filter_is_current_user_authed_for_video( true, $guid, $embedded_post_id );
641        }
642
643        $attachment = false;
644        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
645            $video_info = video_get_info_by_guid( $guid );
646            if ( ! empty( $video_info ) ) {
647                $attachment = get_blog_post( $video_info->blog_id, $video_info->post_id );
648            }
649        } else {
650            $attachment = videopress_get_post_by_guid( $guid );
651        }
652
653        if ( ! $attachment ) {
654            return false;
655        }
656
657        $video_info = video_get_info_by_blogpostid( get_current_blog_id(), $attachment->ID );
658        if ( null === $video_info->guid ) {
659            return false;
660        }
661
662        /*
663         * Default missing privacy_setting to SITE_DEFAULT to avoid an
664         * undefined-property warning and make the site-level fallback explicit.
665         */
666        $privacy_setting = $video_info->privacy_setting ?? VIDEOPRESS_PRIVACY::SITE_DEFAULT;
667
668        $embedded_post_id = (int) $embedded_post_id;
669        if (
670            $embedded_post_id
671            && VIDEOPRESS_PRIVACY::IS_PUBLIC !== $privacy_setting
672            && ! $this->post_embeds_videopress_guid_cached( $embedded_post_id, $guid )
673        ) {
674            $embedded_post_id = 0;
675        }
676
677        $is_user_authed = false;
678
679        // Determine if video is public, private or use site default.
680        switch ( $privacy_setting ) {
681            case VIDEOPRESS_PRIVACY::IS_PUBLIC:
682                $is_user_authed = true;
683                break;
684            case VIDEOPRESS_PRIVACY::IS_PRIVATE:
685                $restriction_details = $this->build_restriction_details( $guid, $embedded_post_id, $selected_plan_id );
686                $is_user_authed      = $restriction_details['can_access'];
687                break;
688            case VIDEOPRESS_PRIVACY::SITE_DEFAULT:
689            default:
690                $is_videopress_private_for_site = Data::get_videopress_videos_private_for_site();
691                $is_user_authed                 = true;
692                if ( $is_videopress_private_for_site ) {
693                    $restriction_details = $this->build_restriction_details( $guid, $embedded_post_id, $selected_plan_id );
694                    $is_user_authed      = $restriction_details['can_access'];
695                }
696        }
697
698        /**
699         * Overrides video view authorization for current user.
700         *
701         * Example of making all videos public:
702         *
703         * function jp_example_override_video_auth( $is_user_authed, $guid ) {
704         *  return true
705         * };
706         * add_filter( 'videopress_is_current_user_authed_for_video', 'jp_example_override_video_auth', 10, 2 );
707         *
708         * @param bool     $is_user_authed   The current user authorization state.
709         * @param string   $guid             The video's unique identifier.
710         * @param int|null $embedded_post_id The post the video is embedded..
711         *
712         * @return bool
713         */
714        return $this->filter_is_current_user_authed_for_video( $is_user_authed, $guid, $embedded_post_id );
715    }
716
717        /**
718         * Overrides video view authorization for current user.
719         *
720         * @param bool     $is_user_authed   The current user authorization state.
721         * @param string   $guid             The video's unique identifier.
722         * @param int|null $embedded_post_id The post the video is embedded..
723         *
724         * @return bool
725         */
726    private function filter_is_current_user_authed_for_video( $is_user_authed, $guid, $embedded_post_id ) {
727        /**
728         * Overrides video view authorization for current user.
729         *
730         * Example of making all videos public:
731         *
732         * function jp_example_override_video_auth( $is_user_authed, $guid ) {
733         *  return true
734         * };
735         * add_filter( 'videopress_is_current_user_authed_for_video', 'jp_example_override_video_auth', 10, 2 );
736         *
737         * @param bool     $is_user_authed   The current user authorization state.
738         * @param string   $guid             The video's unique identifier.
739         * @param int|null $embedded_post_id The post the video is embedded..
740         *
741         * @return bool
742         */
743        return (bool) apply_filters( 'videopress_is_current_user_authed_for_video', $is_user_authed, $guid, $embedded_post_id );
744    }
745
746    /**
747     * Returns the proper blog id depending on Jetpack or WP.com
748     *
749     * @return int the blog id
750     */
751    public function get_videopress_blog_id() {
752        return \Jetpack_Options::get_option( 'id' );
753    }
754}