This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,522 @@
<?php
/**
* VideoPress Access Control.
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service\Abstract_Token_Subscription_Service;
use Automattic\Jetpack\Modules;
use VIDEOPRESS_PRIVACY;
use WP_Post;
/**
* VideoPress video access control utilities.
*
* Note: this is also being used on WordPress.com.
* Use IS_WPCOM checks for functionality that is specific to WPCOM/Jetpack.
*/
class Access_Control {
/**
* Singleton Access_Control instance.
*
* @var Access_Control
**/
private static $instance = null;
/**
* Guid to subscription plan, store, for when used inline on a page.
*
* @var array
*/
private $guids_to_subscriptions = array();
/**
* Set that this guid is controlled by a subscription.
*
* @param string $guid The guid to set.
* @param string|int $subscription_id The subscription to set.
*
* @return Access_Control
*/
public function set_guid_subscription( $guid, $subscription_id ) {
$this->guids_to_subscriptions[ $guid ] = $subscription_id;
return $this;
}
/**
* Get the subscription for a guid.
*
* @param string $guid The guid to get.
*
* @return string|int|false
*/
public function get_subscription_plan_id( $guid ) {
return $this->guids_to_subscriptions[ $guid ] ?? false;
}
/**
* Get the singleton instance.
*
* @return self
*/
public static function instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Determines if Jetpack Memberships are available.
*
* @return bool
*/
private function jetpack_memberships_available() {
return class_exists( '\Jetpack_Memberships' );
}
/**
* Determines if Jetpack Subscriptions are available.
*
* @return bool
*/
private function jetpack_subscriptions_available() {
$is_module_active = ( new Modules() )->is_active( 'subscriptions' );
if ( ! $is_module_active ) {
return false;
}
if ( function_exists( '\Automattic\Jetpack\Extensions\Premium_Content\subscription_service' ) ) {
return true;
}
if ( ! defined( 'JETPACK__PLUGIN_DIR' ) ) {
return false;
}
$subscription_service_file_path = JETPACK__PLUGIN_DIR . 'extensions/blocks/premium-content/_inc/subscription-service/include.php';
if ( ! file_exists( $subscription_service_file_path ) ) {
return false;
}
require_once $subscription_service_file_path;
return function_exists( '\Automattic\Jetpack\Extensions\Premium_Content\subscription_service' );
}
/**
* Check default user access. By default, subscribers or higher can view videos.
*
* @param WP_Post $post_to_check The post to check.
*
* @return bool
**/
private function get_default_user_capability_for_post( $post_to_check ) {
if ( ! isset( $post_to_check->ID ) ) {
return false;
}
$default_auth = current_user_can( 'read_post', $post_to_check->ID );
return $default_auth;
}
/**
* Determines if the current user can access restricted content and builds the restriction_details array.
*
* @param string $guid the video guid.
* @param int $embedded_post_id the post id.
* @param int $selected_plan_id the selected plan id if applicable.
*
* @return array
*/
private function build_restriction_details( $guid, $embedded_post_id, $selected_plan_id ) {
$post_to_check = get_post( $embedded_post_id );
if ( empty( $post_to_check ) ) {
$restriction_details = $this->default_video_restriction_details( false );
return $this->filter_video_restriction_details( $restriction_details, $guid, $embedded_post_id, $selected_plan_id );
}
$default_auth = $this->get_default_user_capability_for_post( $post_to_check );
$restriction_details = $this->default_video_restriction_details( $default_auth );
if ( $this->jetpack_memberships_available() ) {
$post_access_level = \Jetpack_Memberships::get_post_access_level( $embedded_post_id );
if ( 'everybody' !== $post_access_level ) {
$memberships_can_view_post = \Jetpack_Memberships::user_can_view_post( $embedded_post_id );
$restriction_details = $this->get_subscriber_only_restriction_details( $default_auth );
$restriction_details['can_access'] = $memberships_can_view_post;
}
}
return $this->check_block_level_access(
$restriction_details,
$guid,
$embedded_post_id,
$selected_plan_id
);
}
/**
* Determines if the current user can access restricted block content and updates the restriction_details array.
*
* @param array $restriction_details the restriction details array.
* @param string $guid the video guid.
* @param int $embedded_post_id the post id.
* @param int $selected_plan_id the selected plan id if applicable.
*
* @return array
*/
private function check_block_level_access( $restriction_details, $guid, $embedded_post_id, $selected_plan_id ) {
if ( $this->jetpack_subscriptions_available() && $selected_plan_id > 0 ) {
$restriction_details = $this->get_subscriber_only_restriction_details( $restriction_details['can_access'] );
$paywall = \Automattic\Jetpack\Extensions\Premium_Content\subscription_service();
// Only paid subscribers should be granted access to the premium content.
$access_level = '';
if ( class_exists( Abstract_Token_Subscription_Service::class ) ) {
$access_level = Abstract_Token_Subscription_Service::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS;
}
$can_view = $paywall->visitor_can_view_content( array( $selected_plan_id ), $access_level );
$restriction_details['can_access'] = $can_view || current_user_can( 'edit_post', $embedded_post_id ); // Editors can always view the content.
}
return $this->filter_video_restriction_details(
$restriction_details,
$guid,
$embedded_post_id,
$selected_plan_id
);
}
/**
* Returns the default restriction_details for a video.
*
* @param bool $default_can_access The default auth.
*
* @return array
**/
private function get_subscriber_only_restriction_details( $default_can_access = false ) {
return array(
'provider' => 'jetpack_memberships',
'title' => __( 'This video is subscriber-only', 'jetpack-videopress-pkg' ),
'unauthorized_message' => __( 'You need to be subscribed to view this video', 'jetpack-videopress-pkg' ),
'can_access' => $default_can_access,
);
}
/**
* Filters restriction details.
*
* @param array $video_restriction_details The restriction details.
* @param string $guid The video guid.
* @param int $embedded_post_id The post id.
* @param int $selected_plan_id The selected plan id if applicable.
*
* @return array
*/
private function filter_video_restriction_details( $video_restriction_details, $guid, $embedded_post_id, $selected_plan_id ) {
/**
* Filters the video restriction details.
*
* @param array $video_restriction_details The restriction details.
* @param string $guid The video guid.
* @param int $embedded_post_id The post id.
* @param int $selected_plan_id The selected plan id if applicable.
*
* @return array
*/
return (array) apply_filters( 'videopress_video_restriction_details', $video_restriction_details, $guid, $embedded_post_id, $selected_plan_id );
}
/**
* Returns the default restriction_details for a video.
*
* @param bool $default_can_access The default auth.
*
* @return array
**/
private function default_video_restriction_details( $default_can_access = false ) {
$restriction_details = array(
'version' => '1',
'provider' => 'auth',
'title' => __( 'Unauthorized', 'jetpack-videopress-pkg' ),
'unauthorized_message' => __( 'Unauthorized', 'jetpack-videopress-pkg' ),
'can_access' => $default_can_access,
);
return $restriction_details;
}
/**
* Determines whether a given post actually embeds a given VideoPress GUID.
*
* Used to prevent the embedded post id — which arrives from request input — from being
* treated as an authorization context when it has no relationship to the requested video.
* Matching the attachment id itself is not treated as proof of embedding: attachment ids
* are enumerable via the media REST route and would otherwise provide a second path around
* this check whenever the attachment has no parent and falls back to the `read` capability.
*
* @param int $embedded_post_id The post id claimed as the embedding context.
* @param string $guid The video guid.
*
* @return bool
*/
private function post_embeds_videopress_guid( $embedded_post_id, $guid ) {
$post = get_post( $embedded_post_id );
if ( ! $post instanceof WP_Post || empty( $post->post_content ) ) {
return false;
}
// If the guid is nowhere in the content, neither the block nor the shortcode scan can match.
if ( false === strpos( $post->post_content, $guid ) ) {
return false;
}
if ( $this->post_content_has_videopress_block( $post->post_content, $guid ) ) {
return true;
}
if ( $this->post_content_has_videopress_shortcode( $post->post_content, $guid ) ) {
return true;
}
return $this->post_content_has_videopress_url( $post->post_content, $guid );
}
/**
* Walk parsed blocks (including inner blocks) looking for a videopress/video block
* whose guid attribute matches.
*
* @param string $post_content The post content to scan.
* @param string $guid The video guid to match.
*
* @return bool
*/
private function post_content_has_videopress_block( $post_content, $guid ) {
if ( false === strpos( $post_content, 'wp:videopress/video' ) ) {
return false;
}
return $this->blocks_contain_videopress_guid( parse_blocks( $post_content ), $guid );
}
/**
* Recursively scans a parsed block tree for a videopress/video block whose guid attribute matches.
*
* @param array $blocks Parsed blocks (as returned by parse_blocks() or an innerBlocks array).
* @param string $guid The video guid to match.
*
* @return bool
*/
private function blocks_contain_videopress_guid( $blocks, $guid ) {
foreach ( $blocks as $block ) {
if (
isset( $block['blockName'] ) && 'videopress/video' === $block['blockName']
&& isset( $block['attrs']['guid'] ) && $block['attrs']['guid'] === $guid
) {
return true;
}
if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] )
&& $this->blocks_contain_videopress_guid( $block['innerBlocks'], $guid )
) {
return true;
}
}
return false;
}
/**
* Detect a [videopress GUID] or [wpvideo GUID] shortcode whose first positional
* argument matches the given guid.
*
* @param string $post_content The post content to scan.
* @param string $guid The video guid to match.
*
* @return bool
*/
private function post_content_has_videopress_shortcode( $post_content, $guid ) {
if ( false === stripos( $post_content, '[videopress' ) && false === stripos( $post_content, '[wpvideo' ) ) {
return false;
}
$pattern = get_shortcode_regex( array( 'videopress', 'wpvideo' ) );
$count = preg_match_all( '/' . $pattern . '/', $post_content, $matches, PREG_SET_ORDER );
if ( false === $count || 0 === $count ) {
return false;
}
foreach ( $matches as $match ) {
$atts = shortcode_parse_atts( $match[3] );
if ( ! is_array( $atts ) ) {
continue;
}
// Only the positional argument identifies the video; named attributes must not satisfy the binding check.
if ( isset( $atts[0] ) && is_string( $atts[0] ) && $atts[0] === $guid ) {
return true;
}
}
return false;
}
/**
* Detect a canonical VideoPress URL referencing the given guid. Covers oEmbed
* inserts, core/embed blocks, core/video blocks, and core [video] shortcodes
* whose src/mp4 attributes resolve to a VideoPress URL.
*
* @param string $post_content The post content to scan.
* @param string $guid The video guid to match.
*
* @return bool
*/
private function post_content_has_videopress_url( $post_content, $guid ) {
if ( ! preg_match_all( '#https?://[^\s"\'<>)]+#i', $post_content, $matches ) ) {
return false;
}
foreach ( $matches[0] as $url ) {
if ( $guid === Utils::extract_videopress_guid_from_url( $url ) ) {
return true;
}
}
return false;
}
/**
* Determines if the current user can view the provided video. Only ever gets fired if site-wide private videos are enabled.
*
* Filterable for 3rd party plugins.
*
* @param string $guid The video id being checked.
* @param int $embedded_post_id The post id the video is embedded in or 0.
* @param int $selected_plan_id The plan id the earn block this video is embedded in has.
*/
public function is_current_user_authed_for_video( $guid, $embedded_post_id, $selected_plan_id = 0 ) {
if ( current_user_can( 'upload_files' ) ) {
return $this->filter_is_current_user_authed_for_video( true, $guid, $embedded_post_id );
}
$attachment = false;
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$video_info = video_get_info_by_guid( $guid );
if ( ! empty( $video_info ) ) {
$attachment = get_blog_post( $video_info->blog_id, $video_info->post_id );
}
} else {
$attachment = videopress_get_post_by_guid( $guid );
}
if ( ! $attachment ) {
return false;
}
$video_info = video_get_info_by_blogpostid( get_current_blog_id(), $attachment->ID );
if ( null === $video_info->guid ) {
return false;
}
/*
* Default missing privacy_setting to SITE_DEFAULT to avoid an
* undefined-property warning and make the site-level fallback explicit.
*/
$privacy_setting = $video_info->privacy_setting ?? VIDEOPRESS_PRIVACY::SITE_DEFAULT;
$embedded_post_id = (int) $embedded_post_id;
if (
$embedded_post_id
&& VIDEOPRESS_PRIVACY::IS_PUBLIC !== $privacy_setting
&& ! $this->post_embeds_videopress_guid( $embedded_post_id, $guid )
) {
$embedded_post_id = 0;
}
$is_user_authed = false;
// Determine if video is public, private or use site default.
switch ( $privacy_setting ) {
case VIDEOPRESS_PRIVACY::IS_PUBLIC:
$is_user_authed = true;
break;
case VIDEOPRESS_PRIVACY::IS_PRIVATE:
$restriction_details = $this->build_restriction_details( $guid, $embedded_post_id, $selected_plan_id );
$is_user_authed = $restriction_details['can_access'];
break;
case VIDEOPRESS_PRIVACY::SITE_DEFAULT:
default:
$is_videopress_private_for_site = Data::get_videopress_videos_private_for_site();
$is_user_authed = true;
if ( $is_videopress_private_for_site ) {
$restriction_details = $this->build_restriction_details( $guid, $embedded_post_id, $selected_plan_id );
$is_user_authed = $restriction_details['can_access'];
}
}
/**
* Overrides video view authorization for current user.
*
* Example of making all videos public:
*
* function jp_example_override_video_auth( $is_user_authed, $guid ) {
* return true
* };
* add_filter( 'videopress_is_current_user_authed_for_video', 'jp_example_override_video_auth', 10, 2 );
*
* @param bool $is_user_authed The current user authorization state.
* @param string $guid The video's unique identifier.
* @param int|null $embedded_post_id The post the video is embedded..
*
* @return bool
*/
return $this->filter_is_current_user_authed_for_video( $is_user_authed, $guid, $embedded_post_id );
}
/**
* Overrides video view authorization for current user.
*
* @param bool $is_user_authed The current user authorization state.
* @param string $guid The video's unique identifier.
* @param int|null $embedded_post_id The post the video is embedded..
*
* @return bool
*/
private function filter_is_current_user_authed_for_video( $is_user_authed, $guid, $embedded_post_id ) {
/**
* Overrides video view authorization for current user.
*
* Example of making all videos public:
*
* function jp_example_override_video_auth( $is_user_authed, $guid ) {
* return true
* };
* add_filter( 'videopress_is_current_user_authed_for_video', 'jp_example_override_video_auth', 10, 2 );
*
* @param bool $is_user_authed The current user authorization state.
* @param string $guid The video's unique identifier.
* @param int|null $embedded_post_id The post the video is embedded..
*
* @return bool
*/
return (bool) apply_filters( 'videopress_is_current_user_authed_for_video', $is_user_authed, $guid, $embedded_post_id );
}
/**
* Returns the proper blog id depending on Jetpack or WP.com
*
* @return int the blog id
*/
public function get_videopress_blog_id() {
return \Jetpack_Options::get_option( 'id' );
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,293 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\Connection\Client;
/**
* VideoPress AJAX action handlers and utilities.
*
* Note: this is also being used on WordPress.com.
* Use IS_WPCOM checks for functionality that is specific to WPCOM/Jetpack.
*/
class AJAX {
/**
* Singleton AJAX instance.
*
* @var AJAX
**/
private static $instance = null;
/**
* Private AJAX constructor.
*
* Use the AJAX::init() method to get an instance.
*/
private function __construct() {
add_action( 'wp_ajax_videopress-get-upload-token', array( $this, 'wp_ajax_videopress_get_upload_token' ) );
add_action( 'wp_ajax_videopress-get-upload-jwt', array( $this, 'wp_ajax_videopress_get_upload_jwt' ) );
add_action( 'wp_ajax_nopriv_videopress-get-playback-jwt', array( $this, 'wp_ajax_videopress_get_playback_jwt' ) );
add_action( 'wp_ajax_videopress-get-playback-jwt', array( $this, 'wp_ajax_videopress_get_playback_jwt' ) );
add_action(
'wp_ajax_videopress-update-transcoding-status',
array(
$this,
'wp_ajax_update_transcoding_status',
),
-1
);
}
/**
* Initialize the AJAX and get back a singleton instance.
*
* @return AJAX
*/
public static function init() {
if ( self::$instance === null ) {
self::$instance = new AJAX();
}
return self::$instance;
}
/**
* Validate a guid.
*
* @param string $guid The guid to validate.
*
* @return bool
**/
private function is_valid_guid( $guid ) {
if ( empty( $guid ) ) {
return false;
}
preg_match( '/^[a-z0-9]{8}$/i', $guid, $matches );
if ( empty( $matches ) ) {
return false;
}
return true;
}
/**
* Ajax method that is used by the VideoPress player to get a token to play a video.
*
* This is used for both logged in and logged out users.
*
* @return void
*/
public function wp_ajax_videopress_get_playback_jwt() {
$guid = filter_input( INPUT_POST, 'guid' );
$embedded_post_id = filter_input( INPUT_POST, 'post_id', FILTER_VALIDATE_INT );
$selected_plan_id = filter_input( INPUT_POST, 'subscription_plan_id' );
if ( empty( $embedded_post_id ) ) {
$embedded_post_id = 0;
}
if ( empty( $guid ) || ! $this->is_valid_guid( $guid ) ) {
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_error( array( 'message' => __( 'need a guid', 'jetpack-videopress-pkg' ) ), null, JSON_UNESCAPED_SLASHES );
return;
}
if ( ! $this->is_current_user_authed_for_video( $guid, $embedded_post_id, $selected_plan_id ) ) {
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_error( array( 'message' => __( 'You cannot view this video.', 'jetpack-videopress-pkg' ) ), null, JSON_UNESCAPED_SLASHES );
return;
}
$token = $this->request_jwt_from_wpcom( $guid );
if ( empty( $token ) ) {
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_error( array( 'message' => __( 'Could not obtain a VideoPress playback JWT. Please try again later. (empty upload token)', 'jetpack-videopress-pkg' ) ), null, JSON_UNESCAPED_SLASHES );
return;
}
if ( is_wp_error( $token ) ) {
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_error( array( 'message' => __( 'Could not obtain a VideoPress upload JWT. Please try again later.', 'jetpack-videopress-pkg' ) ), null, JSON_UNESCAPED_SLASHES );
return;
}
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_success( array( 'jwt' => $token ), null, JSON_UNESCAPED_SLASHES );
}
/**
* Determines if the current user can view the provided video. Only ever gets fired if site-wide private videos are enabled.
*
* Filterable for 3rd party plugins.
*
* @param string $guid The video id being checked.
* @param int $embedded_post_id The post id the video is embedded in or 0.
* @param int $selected_plan_id The plan id the earn block this video is embedded in has.
*/
private function is_current_user_authed_for_video( $guid, $embedded_post_id, $selected_plan_id = 0 ) {
return Access_Control::instance()->is_current_user_authed_for_video( $guid, $embedded_post_id, $selected_plan_id );
}
/**
* Requests JWT from wpcom.
*
* @param string $guid The video id being checked.
*/
private function request_jwt_from_wpcom( $guid ) {
if ( defined( 'IS_WPCOM' ) && IS_WPCOM && function_exists( 'video_wpcom_get_playback_jwt_for_guid' ) ) {
$jwt_data = video_wpcom_get_playback_jwt_for_guid( $guid );
if ( is_wp_error( $jwt_data ) ) {
return false;
}
return $jwt_data->metadata_token;
}
$video_blog_id = $this->get_videopress_blog_id();
$args = array(
'method' => 'POST',
);
$endpoint = "sites/{$video_blog_id}/media/videopress-playback-jwt/{$guid}";
$result = Client::wpcom_json_api_request_as_blog( $endpoint, 'v2', $args, null, 'wpcom' );
if ( is_wp_error( $result ) ) {
return $result;
}
$response = json_decode( $result['body'], true );
if ( empty( $response['metadata_token'] ) ) {
return false;
}
return $response['metadata_token'];
}
/**
* Ajax method that is used by the VideoPress uploader to get a token to upload a file to the wpcom api.
*
* @return void
*/
public function wp_ajax_videopress_get_upload_jwt() {
if ( ! current_user_can( 'upload_files' ) ) {
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_error( array( 'message' => __( 'You do not have permission to upload files.', 'jetpack-videopress-pkg' ) ), null, JSON_UNESCAPED_SLASHES );
return;
}
$video_blog_id = $this->get_videopress_blog_id();
$args = array(
'method' => 'POST',
);
$endpoint = "sites/{$video_blog_id}/media/videopress-upload-jwt";
$result = Client::wpcom_json_api_request_as_blog( $endpoint, 'v2', $args, null, 'wpcom' );
if ( is_wp_error( $result ) ) {
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_error( array( 'message' => __( 'Could not obtain a VideoPress upload JWT. Please try again later.', 'jetpack-videopress-pkg' ) ), null, JSON_UNESCAPED_SLASHES );
return;
}
$response = json_decode( $result['body'], true );
if ( empty( $response['upload_token'] ) ) {
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_error( array( 'message' => __( 'Could not obtain a VideoPress upload JWT. Please try again later. (empty upload token)', 'jetpack-videopress-pkg' ) ), null, JSON_UNESCAPED_SLASHES );
return;
}
$response['upload_action_url'] = videopress_make_resumable_upload_path( $video_blog_id );
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_success( $response, null, JSON_UNESCAPED_SLASHES );
}
/**
* Ajax method that is used by the VideoPress uploader to get a token to upload a file to the wpcom api.
*
* @return void
*/
public function wp_ajax_videopress_get_upload_token() {
if ( ! current_user_can( 'upload_files' ) ) {
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_error( array( 'message' => __( 'You do not have permission to upload files.', 'jetpack-videopress-pkg' ) ), null, JSON_UNESCAPED_SLASHES );
return;
}
$video_blog_id = $this->get_videopress_blog_id();
$args = array(
'method' => 'POST',
);
$endpoint = "sites/{$video_blog_id}/media/token";
$result = Client::wpcom_json_api_request_as_blog( $endpoint, Client::WPCOM_JSON_API_VERSION, $args );
if ( is_wp_error( $result ) ) {
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_error( array( 'message' => __( 'Could not obtain a VideoPress upload token. Please try again later.', 'jetpack-videopress-pkg' ) ), null, JSON_UNESCAPED_SLASHES );
return;
}
$response = json_decode( $result['body'], true );
if ( empty( $response['upload_token'] ) ) {
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_error( array( 'message' => __( 'Could not obtain a VideoPress upload token. Please try again later.', 'jetpack-videopress-pkg' ) ), null, JSON_UNESCAPED_SLASHES );
return;
}
$response['upload_action_url'] = videopress_make_media_upload_path( $video_blog_id );
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_success( $response, null, JSON_UNESCAPED_SLASHES );
}
/**
* Ajax action to update the video transcoding status from the WPCOM API.
*
* @return void
*/
public function wp_ajax_update_transcoding_status() {
if ( ! isset( $_POST['post_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Informational AJAX response.
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_error( array( 'message' => __( 'A valid post_id is required.', 'jetpack-videopress-pkg' ) ), null, JSON_UNESCAPED_SLASHES );
return;
}
$post_id = (int) $_POST['post_id']; // phpcs:ignore WordPress.Security.NonceVerification.Missing
if ( ! videopress_update_meta_data( $post_id ) ) {
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
wp_send_json_error( array( 'message' => __( 'That post does not have a VideoPress video associated to it.', 'jetpack-videopress-pkg' ) ), null, JSON_UNESCAPED_SLASHES );
return;
}
wp_send_json_success(
array(
'message' => __( 'Status updated', 'jetpack-videopress-pkg' ),
'status' => videopress_get_transcoding_status( $post_id ),
),
null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
JSON_UNESCAPED_SLASHES
);
}
/**
* Returns the proper blog id depending on Jetpack or WP.com
*
* @return int the blog id
*/
public function get_videopress_blog_id() {
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
return get_current_blog_id();
}
$options = Options::get_options();
return $options['shadow_blog_id'];
}
}
@@ -0,0 +1,389 @@
<?php
/**
* VideoPress Attachment_Handler
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Current_Plan;
use WP_Error;
use WP_Post;
/**
* VideoPress Attachment_Handler class.
*/
class Attachment_Handler {
/**
* Initializer
*
* This method should be called only once by the Initializer class. Do not call this method again.
*/
public static function init() {
if ( ! Status::is_active() ) {
return;
}
add_filter( 'wp_get_attachment_url', array( __CLASS__, 'maybe_get_attached_url_for_videopress' ), 10, 2 );
add_filter( 'get_attached_file', array( __CLASS__, 'maybe_get_attached_url_for_videopress' ), 10, 2 );
if ( Current_Plan::supports( 'videopress' ) ) {
add_filter( 'upload_mimes', array( __CLASS__, 'add_video_upload_mimes' ), 999 );
}
add_filter( 'pre_delete_attachment', array( __CLASS__, 'delete_video_wpcom' ), 10, 2 );
add_filter( 'wp_mime_type_icon', array( __CLASS__, 'wp_mime_type_icon' ), 10, 3 );
add_filter( 'wp_video_extensions', array( __CLASS__, 'add_videopress_extenstion' ) );
add_filter( 'wp_prepare_attachment_for_js', array( __CLASS__, 'prepare_attachment_for_js' ) );
add_filter( 'ajax_query_attachments_args', array( __CLASS__, 'ajax_query_attachments_args' ) );
add_action( 'pre_get_posts', array( __CLASS__, 'media_list_table_query' ) );
add_filter( 'user_has_cap', array( __CLASS__, 'disable_delete_if_disconnected' ), 10, 3 );
add_action( 'admin_print_scripts-upload.php', array( __CLASS__, 'enqueue_media_library_poll' ) );
add_action( 'admin_print_styles-upload.php', array( __CLASS__, 'enqueue_media_library_styles' ) );
add_filter( 'heartbeat_received', array( __CLASS__, 'heartbeat_received' ), 10, 2 );
}
/**
* Returns the VideoPress URL for the give post id, otherwise returns the provided default.
*
* This is an attachment-based filter handler.
*
* @param string $default The default return value if post id is not a VideoPress video.
* @param int $post_id The post id for the current attachment.
*/
public static function maybe_get_attached_url_for_videopress( $default, $post_id ) {
$videopress_url = videopress_get_attachment_url( $post_id );
if ( null !== $videopress_url ) {
return $videopress_url;
}
return $default;
}
/**
* Makes sure that all video mimes are added in, as multi site installs can remove them.
*
* @param array $existing_mimes Mime types to extend/filter.
* @return array
*/
public static function add_video_upload_mimes( $existing_mimes = array() ) {
$mime_types = wp_get_mime_types();
$video_types = array_filter( $mime_types, array( __CLASS__, 'filter_video_mimes' ) );
foreach ( $video_types as $key => $value ) {
$existing_mimes[ $key ] = $value;
}
// Make sure that videopress mimes are considered videos.
$existing_mimes['videopress'] = 'video/videopress';
return $existing_mimes;
}
/**
* Filter designed to get rid of non video mime types.
*
* @param string $value Mime type to filter.
* @return int
*/
public static function filter_video_mimes( $value ) {
return preg_match( '@^video/@', $value );
}
/**
* Attempts to delete a VideoPress video from wp.com.
* Will block the deletion from continuing if certain errors return from the wp.com API.
*
* @param Boolean $delete if the deletion should occur or not (unused).
* @param WP_Post $post the post object.
*
* @return null|WP_Error|Boolean null if deletion should continue.
*/
public static function delete_video_wpcom( $delete, $post ) {
if ( ! is_videopress_attachment( $post->ID ) ) {
return null;
}
$guid = get_post_meta( $post->ID, 'videopress_guid', true );
if ( empty( $guid ) ) {
self::delete_video_poster_attachment( $post->ID );
return null;
}
// Phone home and have wp.com delete the VideoPress entry and files.
$wpcom_response = Client::wpcom_json_api_request_as_blog(
sprintf( '/videos/%s/delete', $guid ),
'1.1',
array( 'method' => 'POST' ),
array( 'user_id' => get_current_user_id() )
);
if ( is_wp_error( $wpcom_response ) ) {
return $wpcom_response;
}
// Upon success or a 404 (video already deleted on wp.com), return null to allow the deletion to continue.
if ( 200 === $wpcom_response['response']['code'] || 404 === $wpcom_response['response']['code'] ) {
self::delete_video_poster_attachment( $post->ID );
return null;
}
// Otherwise we stop the deletion from proceeding.
return false;
}
/**
* Deletes a video poster attachment if it exists.
*
* @param int $attachment_id the WP attachment id.
*/
private static function delete_video_poster_attachment( $attachment_id ) {
$thumbnail_id = get_post_meta( $attachment_id, '_thumbnail_id', true );
if ( ! empty( $thumbnail_id ) ) {
// Let's ensure this is a VP poster image before we delete it.
if ( '1' === get_post_meta( $thumbnail_id, 'videopress_poster_image', true ) ) {
// This call triggers the `delete_video_wpcom` filter again but it bails early at the is_videopress_attachment() check.
wp_delete_attachment( $thumbnail_id );
}
}
}
/**
* Filter the mime type icon.
*
* @param string $icon Icon path.
* @param string $mime Mime type.
* @param int $post_id Post ID.
*
* @return string
*/
public static function wp_mime_type_icon( $icon, $mime, $post_id ) {
if ( $mime !== 'video/videopress' ) {
return $icon;
}
$status = get_post_meta( $post_id, 'videopress_status', true );
if ( $status === 'complete' ) {
return $icon;
}
return 'https://wordpress.com/wp-content/mu-plugins/videopress/images/media-video-processing-icon.png';
}
/**
* Filter the list of supported video formats.
*
* @param array $extensions Supported video formats.
*
* @return array
*/
public static function add_videopress_extenstion( $extensions ) {
$extensions[] = 'videopress';
return $extensions;
}
/**
* Make sure that any Video that has a VideoPress GUID passes that data back.
*
* @param array $post Attachment data array.
* @return array
*/
public static function prepare_attachment_for_js( $post ) {
if ( 'video' === $post['type'] ) {
$guid = get_post_meta( $post['id'], 'videopress_guid', true );
if ( $guid ) {
$post['videopress_guid'] = $guid;
}
$status = get_post_meta( $post['id'], 'videopress_status', true );
if ( $status ) {
$post['videopress_status'] = $status;
}
}
return $post;
}
/**
* Media Grid:
* Filter out any videopress video posters that we've downloaded,
* so that they don't seem to display twice.
*
* @param array $args Query variables.
*/
public static function ajax_query_attachments_args( $args ) {
$meta_query = array(
array(
'key' => 'videopress_poster_image',
'compare' => 'NOT EXISTS',
),
);
// If there was already a meta query, let's AND it via
// nesting it with our new one. No need to specify the
// relation, as it defaults to AND.
if ( ! empty( $args['meta_query'] ) ) {
$meta_query[] = $args['meta_query'];
}
$args['meta_query'] = $meta_query;
return $args;
}
/**
* Media List:
* Do the same as `videopress_ajax_query_attachments_args()` but for the list view.
*
* @param array $query WP_Query instance.
*/
public static function media_list_table_query( $query ) {
if (
! function_exists( 'get_current_screen' )
|| get_current_screen() === null
) {
return;
}
if ( is_admin() && $query->is_main_query() && ( 'upload' === get_current_screen()->id ) ) {
$meta_query = array(
array(
'key' => 'videopress_poster_image',
'compare' => 'NOT EXISTS',
),
);
$old_meta_query = $query->get( 'meta_query' );
if ( $old_meta_query ) {
$meta_query[] = $old_meta_query;
}
$query->set( 'meta_query', $meta_query );
}
}
/**
* Filter to disable the `delete_post` capability
* for VideoPress attachments if the current user is
* not connected.
*
* @param array $allcaps All the capabilities of the user.
* @param array $cap [0] Required capability.
* @param array $args [0] Requested capability.
* [1] User ID.
* [2] Associated object ID.
* @return array the filtered array of capabilities.
*/
public static function disable_delete_if_disconnected( $allcaps, $cap, $args ) {
// Only apply this filter to `delete_post` checks
if ( ! isset( $args[0] ) || 'delete_post' !== $args[0] ) {
return $allcaps;
}
// Only apply this filter to VideoPress attachments
if ( ! isset( $args[2] ) || ! is_videopress_attachment( $args[2] ) ) {
return $allcaps;
}
// Set the capability to false if the user can't perform the actions
if ( ! Data::can_perform_action() ) {
$allcaps[ $cap[0] ] = false;
}
return $allcaps;
}
/**
* Enqueues the script that hooks into WP Heartbeat to refresh
* processing VideoPress video data in the media library grid.
*
* @since 0.35.4
*
* @return void
*/
public static function enqueue_media_library_poll() {
wp_enqueue_script(
'videopress-media-library-poll',
plugins_url( 'js/media-library-poll.js', __FILE__ ),
array( 'jquery', 'heartbeat', 'media-grid' ),
Package_Version::PACKAGE_VERSION,
true
);
add_filter( 'heartbeat_settings', array( __CLASS__, 'heartbeat_settings' ) );
}
/**
* Adds inline styles for the media library grid on the upload page.
*
* @since 0.35.4
*
* @return void
*/
public static function enqueue_media_library_styles() {
// Constrain poster images so they fit within the media library grid cell.
wp_add_inline_style(
'media-views',
'.attachment-preview.type-video .thumbnail .centered img.thumbnail { max-width: 100%; max-height: 100%; }'
);
}
/**
* Lowers the Heartbeat minimum interval on the media library page
* so the polling script can request a faster tick rate.
*
* @since 0.35.4
*
* @param array $settings Heartbeat settings.
* @return array
*/
public static function heartbeat_settings( $settings ) {
$settings['minimalInterval'] = 5;
return $settings;
}
/**
* Heartbeat API handler that checks the processing status of VideoPress videos.
*
* @since 0.35.4
*
* @param array $response The Heartbeat response.
* @param array $data The data sent with the Heartbeat request.
* @return array
*/
public static function heartbeat_received( $response, $data ) {
if ( empty( $data['videopress_processing_ids'] ) || ! is_array( $data['videopress_processing_ids'] ) ) {
return $response;
}
$statuses = array();
foreach ( $data['videopress_processing_ids'] as $id ) {
$id = (int) $id;
if ( ! current_user_can( 'edit_post', $id ) ) {
continue;
}
$status = get_post_meta( $id, 'videopress_status', true );
if ( $status ) {
$statuses[ $id ] = $status;
}
}
if ( ! empty( $statuses ) ) {
$response['videopress_processing_status'] = $statuses;
}
return $response;
}
}
@@ -0,0 +1,251 @@
<?php
/**
* VideoPress Block Editor Content
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use WP_Post;
/**
* VideoPress block editor class for content generation
*/
class Block_Editor_Content {
/**
* Initializer
*
* This method should be called only once by the Initializer class. Do not call this method again.
*/
public static function init() {
if ( ! Status::is_standalone_plugin_active() ) {
return;
}
// Remove the videopress shortcodes added by the Jetpack plugin.
if ( shortcode_exists( 'videopress' ) ) {
remove_shortcode( 'videopress' );
}
if ( shortcode_exists( 'wpvideo' ) ) {
remove_shortcode( 'wpvideo' );
}
add_shortcode( 'videopress', array( static::class, 'videopress_embed_shortcode' ) );
add_shortcode( 'wpvideo', array( static::class, 'videopress_embed_shortcode' ) );
add_filter( 'wp_video_shortcode_override', array( static::class, 'video_shortcode_override' ), 10, 4 );
add_filter( 'default_content', array( static::class, 'videopress_video_block_by_guid' ), 10, 2 );
}
/**
* VideoPress embed shortcode
*
* Expected input format:
* [videopress tLvEwHYZ]
*
* @param array $atts Shortcode attributes.
*
* @return string html
*/
public static function videopress_embed_shortcode( $atts ) {
/**
* We only accept GUIDs as a first unnamed argument.
*/
$guid = $atts[0] ?? null;
/**
* Make sure the GUID passed in matches how actual GUIDs are formatted.
*/
if ( ! videopress_is_valid_guid( $guid ) ) {
return '<!-- error: missing or invalid VideoPress video ID -->';
}
/**
* Set the defaults
*/
$defaults = array(
'w' => 640, // Width of the video player, in pixels
'h' => 0, // Height of the video player, in pixels
'at' => 0, // How many seconds in to initially seek to
'loop' => false, // Whether to loop the video repeatedly
'autoplay' => false, // Whether to autoplay the video on load
'cover' => true, // Whether to scale the video to its container
'muted' => false, // Whether the video should start without sound
'controls' => true, // Whether the video should display controls
'playsinline' => false, // Whether the video should be allowed to play inline (for browsers that support this)
'useaveragecolor' => false, // Whether the video should use the seekbar automatic average color
'preloadcontent' => 'metadata',
);
// Make sure "false" will be actually false.
foreach ( $atts as $key => $value ) {
if ( is_string( $value ) && 'false' === strtolower( $value ) ) {
$atts[ $key ] = 0;
}
}
if ( isset( $atts['preload'] ) && videopress_is_valid_preload( $atts['preload'] ) ) {
$atts['preloadcontent'] = $atts['preload'];
}
if ( isset( $atts['preloadcontent'] ) && ! videopress_is_valid_preload( $atts['preloadcontent'] ) ) {
unset( $atts['preloadcontent'] );
}
$atts = shortcode_atts( $defaults, $atts, 'videopress' );
$base_url = 'https://videopress.com/embed/' . $guid;
$query_params = array(
'at' => $atts['at'],
'loop' => $atts['loop'],
'autoplay' => $atts['autoplay'],
'muted' => $atts['muted'],
'controls' => $atts['controls'],
'playsinline' => $atts['playsinline'],
'useAverageColor' => $atts['useaveragecolor'], // The casing is intentional, shortcode params are lowercase, but player expects useAverageColor
'preloadContent' => $atts['preloadcontent'], // The casing is intentional, shortcode params are lowercase, but player expects preloadContent
);
$src = esc_url( add_query_arg( $query_params, $base_url ) );
$width = absint( $atts['w'] );
if ( ! $atts['h'] ) {
$aspect_ratio = 16 / 9; // TODO: Get the correct aspect ratio for the video.
$height = $width / $aspect_ratio;
} else {
$height = absint( $atts['h'] );
}
$cover = $atts['cover'] ? ' data-resize-to-parent="true"' : '';
$block_template =
'<figure class="wp-block-videopress-video wp-block-jetpack-videopress jetpack-videopress-player">' .
'<div class="jetpack-videopress-player__wrapper">' .
'<iframe ' .
'title="' . __( 'VideoPress Video Player', 'jetpack-videopress-pkg' ) . '" ' .
'aria-label="' . __( 'VideoPress Video Player', 'jetpack-videopress-pkg' ) . '" ' .
'src="%s" ' .
'width="%s"' .
'height="%s" ' .
'frameborder="0" ' .
'allowfullscreen%s allow="clipboard-write">' .
'</iframe>' .
'</div>' .
'</figure>';
$version = Package_Version::PACKAGE_VERSION;
Jwt_Token_Bridge::enqueue_jwt_token_bridge();
wp_enqueue_script( 'videopress-iframe', 'https://videopress.com/videopress-iframe.js', array(), $version, true );
return sprintf( $block_template, $src, $width, $height, $cover );
}
/**
* Generates a VideoPress video block content with the given guid
*
* @param string $content Post content.
* @param WP_Post $post Post.
* @return string
*/
public static function videopress_video_block_by_guid( $content, $post ) {
if ( isset( $_GET['videopress_guid'] ) && isset( $_GET['_wpnonce'] )
&& wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'videopress-content-nonce' )
&& current_user_can( 'edit_post', $post->ID )
&& '' === $content
) {
$guid = sanitize_text_field( wp_unslash( $_GET['videopress_guid'] ) );
$base_url = 'https://videopress.com/v/' . $guid;
$query_params = array(
'resizeToParent' => 'true',
'cover' => 'true',
'preloadContent' => 'metadata',
'useAverageColor' => 'true',
);
$url = esc_url( add_query_arg( $query_params, $base_url ) );
if ( ! empty( $guid ) ) {
// ref /client/lib/url/index.ts
$content = '<!-- wp:videopress/video {"guid":"' . $guid . '"} -->
<figure class="wp-block-videopress-video wp-block-jetpack-videopress jetpack-videopress-player">
<div class="jetpack-videopress-player__wrapper">' . $url . '</div>
</figure>
<!-- /wp:videopress/video -->';
}
}
return $content;
}
/**
* Override the standard video short tag to also process videopress files as well.
*
* This will parse the given src and, if it is a videopress file, parse as the
* VideoPress shortcode instead.
*
* @param string $html Empty variable to be replaced with shortcode markup.
* @param array $attr Attributes of the video shortcode.
* @param string $content Video shortcode content.
* @param int $instance Unique numeric ID of this video shortcode instance.
*
* @return string
*/
public static function video_shortcode_override( $html, $attr, $content, $instance ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$videopress_guid = null;
if ( isset( $attr['videopress_guid'] ) ) {
$videopress_guid = $attr['videopress_guid'];
} else {
// Handle the different possible url attributes
$url_keys = array( 'src', 'mp4' );
foreach ( $url_keys as $key ) {
if ( isset( $attr[ $key ] ) ) {
$url = $attr[ $key ];
// phpcs:ignore WordPress.WP.CapitalPDangit
if ( preg_match( '@videos.(videopress\.com|files\.wordpress\.com)/([a-z0-9]{8})/@i', $url, $matches ) ) {
$videopress_guid = $matches[2];
}
// Also test for videopress oembed url, which is used by the Video Media Widget.
if ( ! $videopress_guid && preg_match( '@https://videopress.com/v/([a-z0-9]{8})@i', $url, $matches ) ) {
$videopress_guid = $matches[1];
}
// Also test for old v.wordpress.com oembed URL.
if ( ! $videopress_guid && preg_match( '|^https?://v\.wordpress\.com/([a-zA-Z\d]{8})(.+)?$|i', $url, $matches ) ) { // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText
$videopress_guid = $matches[1];
}
break;
}
}
}
if ( $videopress_guid ) {
$videopress_atts = array( $videopress_guid );
// height is ignored on jetpack video block, so we don't pass it for consistency.
if ( isset( $attr['width'] ) ) {
$videopress_atts['w'] = (int) $attr['width'];
}
if ( isset( $attr['muted'] ) ) {
$videopress_atts['muted'] = $attr['muted'];
}
if ( isset( $attr['autoplay'] ) ) {
$videopress_atts['autoplay'] = $attr['autoplay'];
}
if ( isset( $attr['loop'] ) ) {
$videopress_atts['loop'] = $attr['loop'];
}
// The core video block doesn't support the cover attribute, setting it to false for consistency.
$videopress_atts['cover'] = false;
// Then display the VideoPress version of the stored GUID!
return self::videopress_embed_shortcode( $videopress_atts );
}
return '';
}
}
@@ -0,0 +1,172 @@
<?php
/**
* VideoPress Extensions
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
use Automattic\Jetpack\Constants;
use Automattic\Jetpack\Status\Host;
/**
* VideoPress Extensions class.
*/
class Block_Editor_Extensions {
/**
* What version of the blocks we are loading.
*
* @var string
*/
public static $blocks_variation = 'production';
/**
* Script handle
*
* @var string
*/
public static $script_handle = '';
/**
* Initializer
*
* This method should be called only once by the Block registrar.
* Do not call this method again.
*
* @param string $script_handle - The script handle.
*/
public static function init( $script_handle ) {
if ( ! Status::is_registrant_plugin_active() ) {
return;
}
/*
* Use the videopress/video editor script handle to localize enqueue scripts.
* @see https://developer.wordpress.org/reference/functions/generate_block_asset_handle
*/
self::$script_handle = $script_handle;
/**
* Alternative to `JETPACK_BETA_BLOCKS`, set to `true` to load Beta Blocks.
*
* @since 6.9.0
* @deprecated Jetpack 11.8.0 Use jetpack_blocks_variation filter instead.
*
* @param boolean
*/
if (
apply_filters_deprecated(
'jetpack_load_beta_blocks',
array( false ),
'jetpack-11.8.0',
'jetpack_blocks_variation'
)
) {
self::$blocks_variation = 'beta';
}
/*
* Get block variation, from the new constant or the old one.
*/
if ( Constants::is_true( 'JETPACK_BETA_BLOCKS' ) ) {
self::$blocks_variation = 'beta';
}
$blocks_variation = Constants::get_constant( 'JETPACK_BLOCKS_VARIATION' );
if ( ! empty( $blocks_variation ) ) {
/**
* Allow customizing the variation of blocks in use on a site.
* Overwrites any previously set values, whether by constant or filter.
*
* @since Jetpack 8.1.0
*
* @param string $block_variation Can be beta, experimental, and production. Defaults to production.
*/
self::$blocks_variation = apply_filters( 'jetpack_blocks_variation', $blocks_variation );
}
// Register the script.
add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'enqueue_extensions' ), 1 );
}
/**
* Return the extensions list
*
* @return array The extensions list.
*/
public static function get_list() {
$videopress_extensions_file = __DIR__ . '/../build/block-editor/extensions/index.json';
$videopress_extensions_file_exists = file_exists( $videopress_extensions_file );
if ( ! $videopress_extensions_file_exists ) {
return;
}
$videopress_extensions_data = (array) json_decode(
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
file_get_contents( $videopress_extensions_file )
);
$extensions_list = array_map(
function ( $extension ) {
return array(
'name' => $extension,
'isBeta' => true,
'isEnabled' => 'beta' === self::$blocks_variation,
);
},
$videopress_extensions_data['beta']
);
return $extensions_list;
}
/**
* Check if the extension is available
*
* @param string $slug The extension slug.
* @return boolean True if the extension is available, false otherwise.
*/
public static function is_extension_available( $slug ) {
$extensions_list = self::get_list();
foreach ( $extensions_list as $extension ) {
if ( $extension['name'] === $slug ) {
return $extension['isEnabled'];
}
}
return false;
}
/**
* Enqueues the extensions script.
*/
public static function enqueue_extensions() {
$extensions_list = self::get_list();
$site_type = 'jetpack';
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$site_type = 'simple';
} elseif ( ( new Host() )->is_woa_site() ) {
$site_type = 'atomic';
}
$videopress_editor_state = array(
'extensions' => $extensions_list,
'siteType' => $site_type,
'myJetpackConnectUrl' => admin_url( 'admin.php?page=my-jetpack#/connection' ),
'jetpackVideoPressSettingUrl' => admin_url( 'admin.php?page=jetpack#/settings?term=videopress' ),
'isVideoPressModuleActive' => Status::is_jetpack_plugin_and_videopress_module_active(),
'isStandaloneActive' => Status::is_standalone_plugin_active(),
'imagesURLBase' => plugin_dir_url( __DIR__ ) . 'build/images/',
'playerBridgeUrl' => plugins_url( '../build/lib/player-bridge.js', __FILE__ ),
);
// Expose initial state of site connection
Connection_Initial_State::render_script( self::$script_handle );
// Expose initial state of videoPress editor
wp_localize_script( self::$script_handle, 'videoPressEditorState', $videopress_editor_state );
}
}
@@ -0,0 +1,56 @@
<?php
/**
* Media block replacement class.
*
* @package automattic/jetpack-videopress
**/
namespace Automattic\Jetpack\VideoPress;
/**
* Class Block_Replacement
**/
class Block_Replacement {
/**
* Whether the class has been initiated.
*
* @var bool
*/
private static $initiated = false;
/**
* Initialize replacement.
*/
public static function init() {
if ( self::$initiated ) {
return;
}
add_filter( 'render_block', array( self::class, 'replace_media_text_with_videopress' ), 10, 2 );
}
/**
* Replace video in Media & Text block with Videopress shortcode.
*
* @param string $block_content The block content.
* @param array $block The block.
* @return string
*/
public static function replace_media_text_with_videopress( $block_content, $block ) {
if ( isset( $block['blockName'] ) && $block['blockName'] === 'core/media-text' ) {
// Make sure we have a $post_id that could be valid; if we don't, then video_get_info_by_blogpostid()
// will fail, so there's no point in calling it.
$post_id = isset( $block['attrs']['mediaId'] ) ? (int) $block['attrs']['mediaId'] : 0;
if ( $post_id <= 0 ) {
return $block_content;
}
$video_info = video_get_info_by_blogpostid( get_current_blog_id(), $post_id );
if ( $video_info && $video_info->guid ) {
$videopress_shortcode = sprintf( '[videopress %s]', esc_attr( $video_info->guid ) );
$block_content = preg_replace( '/<video.*?<\/video>/is', do_shortcode( $videopress_shortcode ), $block_content );
}
}
return $block_content;
}
}
@@ -0,0 +1,443 @@
<?php
/**
* The Data class.
* This class provides methods for data VideoPress access and manipulation.
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Status\Host;
use WP_REST_Request;
/**
* The Data class.
*/
class Data {
/**
* Gets the Jetpack blog ID
*
* @return int The blog ID
*/
public static function get_blog_id() {
return VideoPressToken::blog_id();
}
/**
* Gets the VideoPress site privacy configuration.
*
* @return boolean If all the videos are private on the site
*/
public static function get_videopress_videos_private_for_site() {
/**
* If it's a Simple site, returns the site privacy setting.
*/
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
return video_is_private_wpcom_blog( get_current_blog_id() );
}
/**
* If it's a private Atomic site, the default setting is private as well.
*/
if ( ( new Host() )->is_woa_site() ) {
if ( ( intval( get_option( 'blog_public', '' ) ) === -1 ) ) {
return true;
}
}
/* If it's a Jetpack site or a public Atomic site, check the settings */
return boolval( get_option( 'videopress_private_enabled_for_site', false ) );
}
/**
* Gets the VideoPress Settings.
*
* @return array The settings as an associative array.
*/
public static function get_videopress_settings() {
$site_type = 'jetpack';
$site_is_private = false;
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$site_type = 'simple';
$site_is_private = video_is_private_wpcom_blog( get_current_blog_id() );
} elseif ( ( new Host() )->is_woa_site() ) {
$site_type = 'atomic';
$site_is_private = intval( get_option( 'blog_public', '' ) ) === -1;
}
return array(
'videopress_videos_private_for_site' => self::get_videopress_videos_private_for_site(),
'site_is_private' => $site_is_private,
'site_type' => $site_type,
);
}
/**
* Gets the video data
*
* @param boolean $is_videopress - True when getting VideoPress data.
* @return array
*/
public static function get_video_data( $is_videopress = true ) {
$video_data = array(
'videos' => array(),
'total' => 0,
'totalPages' => 0,
'query' => array(
'order' => 'desc',
'orderBy' => 'date',
'itemsPerPage' => 6,
'page' => 1,
),
);
$args = array(
'order' => $video_data['query']['order'],
'orderby' => $video_data['query']['orderBy'],
'per_page' => $video_data['query']['itemsPerPage'],
'page' => $video_data['query']['page'],
);
if ( $is_videopress ) {
$args['mime_type'] = 'video/videopress';
} else {
$args['media_type'] = 'video';
$args['no_videopress'] = true;
}
// Do an internal request for the media list
$request = new WP_REST_Request( 'GET', '/wp/v2/media' );
$request->set_query_params( $args );
$response = rest_do_request( $request );
if ( $response->is_error() ) {
// @todo: error handling
return $video_data;
}
// load the real values
$video_data['videos'] = $response->get_data();
$headers = $response->get_headers();
if ( isset( $headers['X-WP-Total'] ) ) {
$video_data['total'] = $headers['X-WP-Total'];
}
if ( isset( $headers['X-WP-TotalPages'] ) ) {
$video_data['totalPages'] = $headers['X-WP-TotalPages'];
}
return $video_data;
}
/**
* Gets the user data
*
* @return array
*/
public static function get_user_data() {
$user_data = array(
'items' => array(),
'pagination' => array(
'total' => 0,
'totalPages' => 1,
),
'query' => array(
'order' => 'asc',
'orderBy' => 'name',
),
'_meta' => array(
'relyOnInitialState' => true,
),
);
$args = array(
'order' => $user_data['query']['order'],
'orderby' => $user_data['query']['orderBy'],
);
// Do an internal request for the user list
$request = new WP_REST_Request( 'GET', '/wp/v2/users' );
$request->set_query_params( $args );
$response = rest_do_request( $request );
if ( $response->is_error() ) {
// @todo: error handling
return $user_data;
}
// load the real values
$user_data['items'] = $response->get_data();
$headers = $response->get_headers();
if ( isset( $headers['X-WP-Total'] ) ) {
$user_data['pagination']['total'] = $headers['X-WP-Total'];
}
if ( isset( $headers['X-WP-TotalPages'] ) ) {
$user_data['pagination']['totalPages'] = $headers['X-WP-TotalPages'];
}
return $user_data;
}
/**
* Gets the VideoPress used storage space in bytes
*
* @return int the used storage space
*/
public static function get_storage_used() {
$site_data = Site::get_site_info();
if ( is_wp_error( $site_data ) ) {
return 0;
}
if ( isset( $site_data['options'] ) && isset( $site_data['options']['videopress_storage_used'] ) ) {
return intval( round( $site_data['options']['videopress_storage_used'] * 1024 * 1024 ) );
} else {
return 0;
}
}
/**
* Return all the initial state that depends on a valid site connection
*
* @return array
*/
public static function get_connected_initial_state() {
return array(
'videos' => array(
'storageUsed' => self::get_storage_used(),
),
'purchases' => array(
'items' => Site::get_purchases(),
'isFetching' => false,
),
);
}
/**
* Checks if the site has as connected owner
*/
public static function has_connected_owner() {
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
return true;
}
if ( ( new Connection_Manager() )->has_connected_owner() ) {
return true;
}
return false;
}
/**
* Checks if the user is able to perform actions that modify data
*/
public static function can_perform_action() {
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
return true;
}
$connection = new Connection_Manager();
return (
$connection->is_connected() &&
self::has_connected_owner() &&
$connection->is_user_connected()
);
}
/**
* Return the initial state of the VideoPress app,
* used to render initially the app in the frontend.
*
* @return array
*/
public static function get_initial_state() {
$videopress_data = self::get_video_data();
$local_videos_data = self::get_video_data( false );
// Tweak local videos data.
$local_videos = array_map(
function ( $video ) {
$video = (array) $video;
$id = $video['id'];
$media_details = $video['media_details'];
$jetpack_videopress = $video['jetpack_videopress'];
$read_error = null;
// In malformed files, the media_details or jetpack_videopress properties are not arrays.
if ( ! is_array( $media_details ) || ! is_array( $jetpack_videopress ) ) {
$media_details = (array) $media_details;
$jetpack_videopress = (array) $jetpack_videopress;
$read_error = Upload_Exception::ERROR_MALFORMED_FILE;
}
// Check if video is already uploaded to VideoPress or has some error.
try {
$uploader = new Uploader( $id );
$is_uploaded_to_videopress = $uploader->is_uploaded();
} catch ( Upload_Exception $e ) {
$is_uploaded_to_videopress = false;
$read_error = $e->getCode();
}
$upload_date = $video['date'];
$url = $video['source_url'];
$title = $jetpack_videopress['title'];
$description = $jetpack_videopress['description'];
$caption = $jetpack_videopress['caption'];
$width = $media_details['width'] ?? null;
$height = $media_details['height'] ?? null;
$duration = $media_details['length'] ?? null;
return array(
'id' => $id,
'title' => $title,
'description' => $description,
'caption' => $caption,
'width' => $width,
'height' => $height,
'url' => $url,
'uploadDate' => $upload_date,
'duration' => $duration,
'isUploadedToVideoPress' => $is_uploaded_to_videopress,
'readError' => $read_error,
);
},
$local_videos_data['videos']
);
// Tweak VideoPress videos data.
$videos = array_map(
array( __CLASS__, 'prepare_videopress_video_data' ),
$videopress_data['videos']
);
$site_settings = self::get_videopress_settings();
$initial_state = array(
'users' => self::get_user_data(),
'siteSettings' => array(
'videoPressVideosPrivateForSite' => $site_settings['videopress_videos_private_for_site'],
'siteIsPrivate' => $site_settings['site_is_private'],
'siteType' => $site_settings['site_type'],
),
'videos' => array(
'uploadedVideoCount' => $videopress_data['total'],
'items' => $videos,
'isFetching' => false,
'isFetchingUploadedVideoCount' => false,
'pagination' => array(
'totalPages' => $videopress_data['totalPages'],
'total' => $videopress_data['total'],
),
'query' => $videopress_data['query'],
'_meta' => array(
'processedAllVideosBeingRemoved' => true,
'relyOnInitialState' => true,
),
),
'localVideos' => array(
'uploadedVideoCount' => $local_videos_data['total'],
'items' => $local_videos,
'isFetching' => false,
'isFetchingUploadedVideoCount' => false,
'pagination' => array(
'totalPages' => $local_videos_data['totalPages'],
'total' => $local_videos_data['total'],
),
'query' => $local_videos_data['query'],
'_meta' => array(
'relyOnInitialState' => true,
),
),
);
if ( self::has_connected_owner() ) {
return array_merge_recursive( $initial_state, self::get_connected_initial_state() );
}
return $initial_state;
}
/**
* Maps a single VideoPress video, as returned by the media REST endpoint, to
* the shape consumed by the VideoPress dashboard app.
*
* Videos that are still processing (or otherwise have partial metadata) come
* back without `videopress`, `width` or `height` in their media_details, so
* every nested access is coalesced to avoid undefined-key and offset-on-null
* warnings.
*
* @param array|object $video A single video entry from the media REST endpoint.
* @return array The video data formatted for the dashboard app.
*/
private static function prepare_videopress_video_data( $video ) {
$video = (array) $video;
$id = $video['id'];
$guid = $video['jetpack_videopress_guid'];
$media_details = (array) $video['media_details'];
$jetpack_videopress = (array) $video['jetpack_videopress'];
$videopress_media_details = $media_details['videopress'] ?? array();
$width = $media_details['width'] ?? null;
$height = $media_details['height'] ?? null;
$title = $jetpack_videopress['title'];
$description = $jetpack_videopress['description'];
$caption = $jetpack_videopress['caption'];
$rating = $jetpack_videopress['rating'];
$allow_download = $jetpack_videopress['allow_download'];
$display_embed = $jetpack_videopress['display_embed'];
$privacy_setting = $jetpack_videopress['privacy_setting'];
$needs_playback_token = $jetpack_videopress['needs_playback_token'];
$is_private = $jetpack_videopress['is_private'];
$original = $videopress_media_details['original'] ?? null;
$poster = ( ! $needs_playback_token ) ? ( $videopress_media_details['poster'] ?? null ) : null;
$upload_date = $videopress_media_details['upload_date'] ?? null;
$duration = $videopress_media_details['duration'] ?? null;
$file_url_base = $videopress_media_details['file_url_base'] ?? null;
$finished = $videopress_media_details['finished'] ?? null;
$files = $videopress_media_details['files'] ?? null;
$filename = $original !== null ? basename( $original ) : null;
if ( isset( $files['dvd']['original_img'] ) && isset( $file_url_base['https'] ) && $privacy_setting !== 1 ) {
$thumbnail = $file_url_base['https'] . $files['dvd']['original_img'];
} else {
$thumbnail = null;
}
return array(
'id' => $id,
'guid' => $guid,
'title' => $title,
'description' => $description,
'caption' => $caption,
'url' => $original,
'uploadDate' => $upload_date,
'duration' => $duration,
'isPrivate' => $is_private,
'posterImage' => $poster,
'allowDownload' => $allow_download,
'displayEmbed' => $display_embed,
'rating' => $rating,
'privacySetting' => $privacy_setting,
'needsPlaybackToken' => $needs_playback_token,
'width' => $width,
'height' => $height,
'poster' => array(
'src' => $poster,
),
'thumbnail' => $thumbnail,
'finished' => $finished,
'filename' => $filename,
);
}
}
@@ -0,0 +1,95 @@
<?php
/**
* Divi integration for Videopress.
*
* @package automattic/jetpack-videopress
**/
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\VideoPress\Divi5\Divi_5;
/**
* Class Divi
**/
class Divi {
/**
* The instance.
*
* @var Divi
**/
private static $instance = null;
/**
* Running or not.
*
* @var bool
**/
private $running = false;
/**
* VideoPress Divi Extension object.
*
* @var ?\VideoPress_Divi_Extension
**/
private $vidi_extension;
/**
* Initializes VideoPress/Divi integration.
*
* Called only once by the Initializer class
*
* @return self
*/
public static function init() {
return self::get_instance()->run();
}
/**
* Get the instance.
*
* @return self
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Run the extension.
*
* @return self
*/
public function run() {
if ( $this->running ) {
return $this;
}
$this->running = true;
add_action( 'divi_extensions_init', array( $this, 'initialize_extension' ) );
add_action( 'et_fb_framework_loaded', array( $this, 'on_fb_framework_loaded' ) );
// Divi 5 integration. These hooks only fire under Divi 5; the legacy
// extension above continues to serve Divi 4.
Divi_5::init();
return $this;
}
/**
* Fires when the Frontend Builder is loaded.
*/
public function on_fb_framework_loaded() {
Jwt_Token_Bridge::enqueue_jwt_token_bridge();
}
/**
* Creates the extension's main class instance.
*/
public function initialize_extension() {
require_once plugin_dir_path( __FILE__ ) . 'videopress-divi/class-videopress-divi-extension.php';
$this->vidi_extension = new \VideoPress_Divi_Extension();
}
}
@@ -0,0 +1,217 @@
<?php
/**
* The modernized VideoPress dashboard React initial state.
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\My_Jetpack\Product;
use Automattic\Jetpack\My_Jetpack\Products as My_Jetpack_Products;
use Automattic\Jetpack\Status;
use Automattic\Jetpack\Status\Host;
use Jetpack_Options;
use function admin_url;
use function esc_url_raw;
use function get_bloginfo;
use function get_locale;
use function get_option;
use function get_site_url;
use function plugins_url;
use function rest_url;
use function wp_add_inline_script;
use function wp_create_nonce;
use function wp_json_encode;
use function wp_parse_url;
use function wp_script_is;
/**
* The modernized VideoPress dashboard React initial state.
*
* Mirrors the Activity Log dashboard pattern: a small structured payload
* inlined as `var JPVIDEOPRESS_INITIAL_STATE=...` before the wp-build boot
* script runs. Phases 6 and 8 will consume the payload via per-file
* `declare const` ambient types.
*/
class Initial_State {
const SCRIPT_HANDLE = 'jetpack-videopress-dashboard-wp-admin-prerequisites';
/**
* WPCOM product slug purchased by the dashboard's upgrade CTA.
*
* Mirrors the product `Plan::get_product()` resolves to
* (`$products->jetpack_videopress`). Kept as a constant rather than read
* from `Plan::get_product()` because that helper issues a synchronous
* WPCOM request, which we don't want to incur on every page render.
*/
const VIDEOPRESS_PRODUCT_SLUG = 'jetpack_videopress';
/**
* Register the inline-state enqueue hook.
*
* Hooks `admin_enqueue_scripts` at priority 11 so the wp-build page
* (`build/pages/jetpack-videopress-dashboard/page-wp-admin.php`) has
* already registered the prerequisites handle at its default priority 10.
* The `wp_script_is( ..., 'registered' )` check inside the callback
* doubles as the page guard.
*
* @return void
*/
public static function init() {
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue' ), 11 );
}
/**
* Inline the initial-state payload before the wp-build boot script.
*
* @return void
*/
public static function enqueue() {
if ( ! Admin_UI::is_modernized() ) {
return;
}
if ( ! wp_script_is( self::SCRIPT_HANDLE, 'registered' ) ) {
return;
}
wp_add_inline_script( self::SCRIPT_HANDLE, ( new self() )->render(), 'before' );
// Hydrate the JP connection store (`window.JP_CONNECTION_INITIAL_STATE`)
// so shared connection-aware hooks — notably
// `useProductCheckoutWorkflow`, which powers the upgrade CTA — work on
// the modernized dashboard exactly as they do on the legacy one. This
// also sets `window.jpTracksContext.blog_id` for `@automattic/jetpack-analytics`.
Connection_Initial_State::render_script( self::SCRIPT_HANDLE );
}
/**
* Get the initial state data.
*
* @return array
*/
private function get_data() {
$gmt_offset = get_option( 'gmt_offset' );
$timezone_string = get_option( 'timezone_string' );
$home_host = wp_parse_url( get_site_url(), PHP_URL_HOST );
return array(
'API' => array(
'WP_API_root' => esc_url_raw( rest_url() ),
'WP_API_nonce' => wp_create_nonce( 'wp_rest' ),
'contentNonce' => wp_create_nonce( 'videopress-content-nonce' ),
),
'jetpackStatus' => array(
'calypsoSlug' => ( new Status() )->get_site_suffix(),
),
'product' => array(
// Fed to `useProductCheckoutWorkflow` as the product to purchase.
'slug' => self::VIDEOPRESS_PRODUCT_SLUG,
),
'siteData' => array(
'id' => Jetpack_Options::get_option( 'id' ),
'title' => get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : get_site_url(),
'adminUrl' => esc_url_raw( admin_url() ),
'slug' => is_string( $home_host ) ? $home_host : '',
'gmtOffset' => is_numeric( $gmt_offset ) ? (float) $gmt_offset : 0.0,
'timezoneString' => is_string( $timezone_string ) ? $timezone_string : '',
'locale' => str_replace( '_', '-', (string) get_locale() ),
// Paid-tier capability check. Drives the free-tier UX (callout /
// DataViews configuration) once designer input lands. Backed by
// `Product::get_site_features_from_wpcom()`, which caches the WPCOM
// `/sites/%d/features` response in a 15-second site transient. On a
// cache miss this issues a synchronous WPCOM request that blocks
// page rendering until it returns.
'hasVideoPressAccess' => self::has_videopress_access(),
'isVideoPress1TB' => self::has_videopress_feature( 'videopress-1tb-storage' ),
'isVideoPressUnlimited' => self::has_videopress_feature( 'videopress-unlimited-storage' ),
),
'assets' => array(
'buildUrl' => plugins_url( '../build/', __FILE__ ),
),
// Authoritative map of accepted upload types (extension => mimetype),
// so the dashboard's drag-and-drop filter accepts exactly what the
// VideoPress backend supports rather than guessing client-side.
'allowedVideoExtensions' => Admin_UI::get_allowed_video_extensions(),
// Product/pricing payload for the pre-connection upsell. Only
// populated when the site isn't connected — the only time the
// connection gate renders the upsell — so connected dashboards never
// incur the synchronous WPCOM pricing request `get_pricing_data()`
// makes.
'pricing' => ( new Connection_Manager() )->is_connected() ? null : $this->get_pricing_data(),
);
}
/**
* Product description, feature list, and yearly price for the
* pre-connection upsell (a port of the legacy dashboard's pricing table).
*
* Backed by `Plan::get_product_price()`, which issues a synchronous WPCOM
* request, so this only runs for disconnected sites. Returns null when the
* product or price data isn't available (e.g. the WPCOM request fails), in
* which case the gate falls back to the plain connect screen.
*
* @return array|null The upsell payload, or null when it can't be built.
*/
private function get_pricing_data() {
$site_product = My_Jetpack_Products::get_product( 'videopress' );
$product_price = Plan::get_product_price();
if ( ! is_array( $site_product ) || ! isset( $product_price['yearly'] ) ) {
return null;
}
return array(
'title' => isset( $site_product['description'] ) ? (string) $site_product['description'] : '',
'features' => isset( $site_product['features'] ) ? array_values( (array) $site_product['features'] ) : array(),
'yearly' => $product_price['yearly'],
);
}
/**
* Whether the site has any paid VideoPress feature flag active.
*
* Matches the active-features check used by the existing
* `videopress/v1/features` REST endpoint: any of the storage tiers
* counts as paid access. On WPCOM the legacy `videopress` slug also
* grants access.
*
* @return bool
*/
public static function has_videopress_access() {
return self::has_videopress_feature( 'videopress-1tb-storage' )
|| self::has_videopress_feature( 'videopress-unlimited-storage' )
|| ( ( new Host() )->is_wpcom_platform() && self::has_videopress_feature( 'videopress' ) );
}
/**
* Whether the named feature appears in the WPCOM active-features list.
*
* @param string $feature_slug Feature slug as returned by WPCOM (e.g. `videopress-1tb-storage`).
* @return bool
*/
private static function has_videopress_feature( $feature_slug ) {
$features = Product::get_site_features_from_wpcom();
if ( is_wp_error( $features ) ) {
return false;
}
$active = $features['active'] ?? array();
return in_array( $feature_slug, $active, true );
}
/**
* Render the initial state into a JavaScript variable.
*
* @return string
*/
public function render() {
return 'var JPVIDEOPRESS_INITIAL_STATE=' . wp_json_encode( $this->get_data(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';';
}
}
@@ -0,0 +1,514 @@
<?php
/**
* The initializer class for the videopress package
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
/**
* Initialized the VideoPress package
*/
class Initializer {
const JETPACK_VIDEOPRESS_IFRAME_API_HANDLER = 'jetpack-videopress-iframe-api';
/**
* Initialization optinos
*
* @var array
*/
protected static $init_options = array();
/**
* Initializes the VideoPress package
*
* This method is called by Config::ensure.
*
* @return void
*/
public static function init() {
if ( ! did_action( 'videopress_init' ) ) {
self::unconditional_initialization();
if ( Status::is_active() ) {
self::active_initialization();
}
}
/**
* Fires after the VideoPress package is initialized
*
* @since 0.1.1
*/
do_action( 'videopress_init' );
}
/**
* Update the initialization options
*
* This method is called by the Config class
*
* @param array $options The initialization options.
* @return void
*/
public static function update_init_options( array $options ) {
if ( empty( $options['admin_ui'] ) || self::should_initialize_admin_ui() ) { // do not overwrite if already set to true.
return;
}
self::$init_options['admin_ui'] = $options['admin_ui'];
}
/**
* Checks the initialization options and returns whether the admin_ui should be initialized or not
*
* @return boolean
*/
public static function should_initialize_admin_ui() {
return isset( self::$init_options['admin_ui'] ) && true === self::$init_options['admin_ui'];
}
/**
* Initialize VideoPress features that should be initialized whenever VideoPress is present, even if the module is not active
*
* @return void
*/
private static function unconditional_initialization() {
if ( self::should_include_utilities() ) {
require_once __DIR__ . '/utility-functions.php';
}
// Set up package version hook.
add_filter( 'jetpack_package_versions', __NAMESPACE__ . '\Package_Version::send_package_version_to_tracker' );
Module_Control::init();
/*
* The WPCOM REST API v2 endpoints only register routes/fields on REST
* init, so defer constructing them (and autoloading their classes) until
* a REST request is actually served. Registered on both REST init hooks
* so the routes remain available in every context they were before, and
* guarded so the endpoints are instantiated only once per request.
*/
$register_rest_api_v2_endpoints = static function () {
static $registered = false;
if ( $registered ) {
return;
}
$registered = true;
new WPCOM_REST_API_V2_Endpoint_VideoPress();
new WPCOM_REST_API_V2_Attachment_VideoPress_Field();
new WPCOM_REST_API_V2_Attachment_VideoPress_Data();
};
add_action( 'rest_api_init', $register_rest_api_v2_endpoints, 0 );
add_action( 'restapi_theme_init', $register_rest_api_v2_endpoints, 0 );
if ( is_admin() ) {
AJAX::init();
} else {
require_once __DIR__ . '/class-block-replacement.php';
Block_Replacement::init();
}
}
/**
* This avoids conflicts when running VideoPress plugin with older versions of the Jetpack plugin
*
* On version 11.3-a.7 utility functions include were removed from the plugin and it is safe to include it from the package
*
* @return boolean
*/
private static function should_include_utilities() {
if ( ! class_exists( 'Jetpack' ) || ! defined( 'JETPACK__VERSION' ) ) {
return true;
}
return version_compare( JETPACK__VERSION, '11.3-a.7', '>=' );
}
/**
* Initialize VideoPress features that should be initialized only when the module is active
*
* @return void
*/
private static function active_initialization() {
Attachment_Handler::init();
Jwt_Token_Bridge::init();
Initial_State::init();
XMLRPC::init();
Block_Editor_Content::init();
/*
* These endpoints only add their routes on REST init, so defer calling
* init() (and autoloading the endpoint classes) until a REST request is
* served. Priority 0 ensures the routes still register before the
* default-priority rest_api_init handlers run. Class-name strings are
* used so the classes are not autoloaded on non-REST requests.
*/
foreach (
array(
Uploader_Rest_Endpoints::class,
Rest_Controller::class,
VideoPress_Rest_Api_V1_Stats::class,
VideoPress_Rest_Api_V1_Site::class,
VideoPress_Rest_Api_V1_Settings::class,
VideoPress_Rest_Api_V1_Features::class,
) as $rest_endpoint
) {
add_action( 'rest_api_init', array( $rest_endpoint, 'init' ), 0 );
}
self::register_oembed_providers();
// Enqueuethe VideoPress Iframe API script in the front-end.
add_filter( 'embed_oembed_html', array( __CLASS__, 'enqueue_videopress_iframe_api_script' ), 10, 4 );
if ( self::should_initialize_admin_ui() ) {
Admin_UI::init();
}
Divi::init();
}
/**
* Explicitly register VideoPress oembed provider for patterns not supported by core
*
* @return void
*/
public static function register_oembed_providers() {
$host = rawurlencode( home_url() );
// videopress.com/v is already registered in core.
// By explicitly declaring the provider here, we can speed things up by not relying on oEmbed discovery.
wp_oembed_add_provider( '#^https?://video.wordpress.com/v/.*#', 'https://public-api.wordpress.com/oembed/?for=' . $host, true );
// This is needed as it's not supported in oEmbed discovery
wp_oembed_add_provider( '|^https?://v\.wordpress\.com/([a-zA-Z\d]{8})(.+)?$|i', 'https://public-api.wordpress.com/oembed/?for=' . $host, true ); // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText
add_filter( 'embed_oembed_html', array( __CLASS__, 'video_enqueue_bridge_when_oembed_present' ), 10, 4 );
}
/**
* Enqueues VideoPress token bridge when a VideoPress oembed is present on the current page.
*
* @param string|false $cache The cached HTML result, stored in post meta.
* @param string $url The attempted embed URL.
* @param array $attr An array of shortcode attributes.
* @param int $post_ID Post ID.
*
* @return string|false
*/
public static function video_enqueue_bridge_when_oembed_present( $cache, $url, $attr, $post_ID = null ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( Utils::is_videopress_url( $url ) ) {
Jwt_Token_Bridge::enqueue_jwt_token_bridge();
}
return $cache;
}
/**
* Register all VideoPress blocks
*
* @return void
*/
public static function register_videopress_blocks() {
// Register VideoPress Video block.
self::register_videopress_video_block();
}
/**
* VideoPress video block render method
*
* @global \WP_Embed $wp_embed WordPress embed handler.
*
* @param array $block_attributes Block attributes.
* @param string $content Current block markup.
* @param \WP_Block $block Current block.
*
* @return string Block markup.
*/
public static function render_videopress_video_block( $block_attributes, $content, $block ) {
global $wp_embed;
// CSS classes.
$align = $block_attributes['align'] ?? null;
$align_class = $align ? ' align' . $align : '';
$custom_class = isset( $block_attributes['className'] ) ? ' ' . $block_attributes['className'] : '';
$classes = 'wp-block-jetpack-videopress jetpack-videopress-player' . $custom_class . $align_class;
// Inline style.
$style = '';
$max_width = $block_attributes['maxWidth'] ?? null;
if ( $max_width && $max_width !== '100%' ) {
$style = sprintf( 'max-width: %s;', $max_width );
$classes .= ' wp-block-jetpack-videopress--has-max-width';
}
/*
* <figcaption /> element
* Caption is stored into the block attributes,
* but also it was stored into the <figcaption /> element,
* meaning that it could be stored in two different places.
*/
$figcaption = '';
// Caption from block attributes.
$caption = $block_attributes['caption'] ?? null;
/*
* If the caption is not stored into the block attributes,
* try to get it from the <figcaption /> element.
*/
if ( $caption === null ) {
preg_match( '/<figcaption>(.*?)<\/figcaption>/', $content, $matches );
$caption = $matches[1] ?? null;
}
// If we have a caption, create the <figcaption /> element.
if ( $caption !== null ) {
$figcaption = sprintf( '<figcaption>%s</figcaption>', wp_kses_post( $caption ) );
}
// Custom anchor from block content.
$id_attribute = '';
// Try to get the custom anchor from the block attributes.
if ( isset( $block_attributes['anchor'] ) && $block_attributes['anchor'] ) {
$id_attribute = sprintf( 'id="%s"', esc_attr( $block_attributes['anchor'] ) );
} elseif ( preg_match( '/<figure[^>]*id="([^"]+)"/', $content, $matches ) ) {
// Otherwise, try to get the custom anchor from the <figure /> element.
$id_attribute = sprintf( 'id="%s"', esc_attr( $matches[1] ) );
}
// Preview On Hover data.
$is_poh_enabled =
isset( $block_attributes['posterData']['previewOnHover'] ) &&
$block_attributes['posterData']['previewOnHover'];
$autoplay = $block_attributes['autoplay'] ?? false;
$controls = $block_attributes['controls'] ?? false;
$poster = $block_attributes['posterData']['url'] ?? null;
$preview_on_hover = '';
if ( $is_poh_enabled ) {
$preview_on_hover = array(
'previewAtTime' => $block_attributes['posterData']['previewAtTime'],
'previewLoopDuration' => $block_attributes['posterData']['previewLoopDuration'],
'autoplay' => $autoplay,
'showControls' => $controls,
);
// Create inline style in case video has a custom poster.
$inline_style = '';
if ( $poster ) {
$inline_style = sprintf(
'style="background-image: url(%s); background-size: cover; background-position: center center;"',
esc_attr( $poster )
);
}
// Expose the preview on hover data to the client.
$preview_on_hover = sprintf(
'<div class="jetpack-videopress-player__overlay" %s></div><script type="application/json">%s</script>',
$inline_style,
wp_json_encode( $preview_on_hover, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP )
);
// Set `autoplay` and `muted` attributes to the video element.
$block_attributes['autoplay'] = true;
$block_attributes['muted'] = true;
}
$figure_template = '
<figure class="%1$s" style="%2$s" %3$s>
%4$s
%5$s
%6$s
</figure>
';
// VideoPress URL.
$guid = $block_attributes['guid'] ?? null;
$videopress_url = Utils::get_video_press_url( $guid, $block_attributes );
$video_wrapper = '';
$video_wrapper_classes = 'jetpack-videopress-player__wrapper';
if ( $videopress_url ) {
$videopress_url = wp_kses_post( $videopress_url );
/*
* Provide a fallback iframe for when the oEmbed endpoint fails, e.g.
* when the VideoPress backend isn't ready for a freshly uploaded video.
* This prevents the published page from showing a bare link.
*/
$fallback = function ( $output, $url ) use ( $videopress_url ) {
if ( $url !== html_entity_decode( $videopress_url, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) ) {
return $output;
}
return sprintf(
'<iframe title="%1$s" aria-label="%1$s" src="%2$s" width="640" height="360" allowfullscreen data-resize-to-parent="true" allow="clipboard-write"></iframe>',
esc_attr__( 'VideoPress Video Player', 'jetpack-videopress-pkg' ),
esc_url( preg_replace( '#/v/#', '/embed/', $url, 1 ) )
);
};
add_filter( 'embed_maybe_make_link', $fallback, 10, 2 );
$oembed_html = apply_filters( 'video_embed_html', $wp_embed->shortcode( array(), $videopress_url ) );
remove_filter( 'embed_maybe_make_link', $fallback );
$video_wrapper = sprintf(
'<div class="%s">%s %s</div>',
$video_wrapper_classes,
$preview_on_hover,
$oembed_html
);
/*
* Self-heal failed oEmbed cache for VideoPress URLs.
*
* When the VideoPress backend isn't ready for a freshly uploaded video,
* WordPress caches '{{unknown}}' in post meta with a TTL that is too long
* for this use case. Clear recent failures so the next page render retries
* oEmbed discovery, keeping the fallback iframe above temporary.
*/
$post_id = $block->context['postId'] ?? get_the_ID();
if ( $post_id ) {
$key_suffix = md5( $videopress_url . serialize( wp_embed_defaults( $videopress_url ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Matching WP_Embed cache key format.
$oembed_value = get_post_meta( $post_id, '_oembed_' . $key_suffix, true );
$oembed_time = (int) get_post_meta( $post_id, '_oembed_time_' . $key_suffix, true );
/*
* Only clear the '{{unknown}}' cache entry when it is recent, to avoid
* disabling WordPress's oEmbed backoff for persistent provider failures.
*/
if (
'{{unknown}}' === $oembed_value
&& ( ! $oembed_time || ( time() - $oembed_time ) < MINUTE_IN_SECONDS )
) {
delete_post_meta( $post_id, '_oembed_' . $key_suffix );
delete_post_meta( $post_id, '_oembed_time_' . $key_suffix );
}
}
}
// Get premium content from block context.
$premium_block_plan_id = isset( $block->context['premium-content/planId'] ) ? intval( $block->context['premium-content/planId'] ) : 0;
$is_premium_content_child = isset( $block->context['isPremiumContentChild'] ) ? (bool) $block->context['isPremiumContentChild'] : false;
$maybe_premium_script = '';
if ( $is_premium_content_child ) {
Access_Control::instance()->set_guid_subscription( $guid, $premium_block_plan_id );
$escaped_guid = wp_json_encode( $guid, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
$script_content = "if ( ! window.__guidsToPlanIds ) { window.__guidsToPlanIds = {}; }; window.__guidsToPlanIds[$escaped_guid] = $premium_block_plan_id;";
$maybe_premium_script = '<script>' . $script_content . '</script>';
}
// $id_attribute, $video_wrapper, $figcaption properly escaped earlier in the code.
return sprintf(
$figure_template,
esc_attr( $classes ),
esc_attr( $style ),
$id_attribute,
$video_wrapper,
$figcaption,
$maybe_premium_script
);
}
/**
* Register the VideoPress block editor block,
* AKA "VideoPress Block v6".
*
* @return void
*/
public static function register_videopress_video_block() {
/*
* If only Jetpack is active, and if the VideoPress module is not active,
* we can register the block just to display a placeholder to turn on the module.
* That invitation is only useful for admins though.
*/
if (
Status::is_jetpack_plugin_without_videopress_module_active()
&& ! Status::is_standalone_plugin_active()
&& ! current_user_can( 'jetpack_activate_modules' )
) {
return;
}
$videopress_video_metadata_file = __DIR__ . '/../build/block-editor/blocks/video/block.json';
$videopress_video_metadata_file_exists = file_exists( $videopress_video_metadata_file );
if ( ! $videopress_video_metadata_file_exists ) {
return;
}
$videopress_video_metadata = json_decode(
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
file_get_contents( $videopress_video_metadata_file )
);
// Pick the block name straight from the block metadata .json file.
$videopress_video_block_name = $videopress_video_metadata->name;
// Is the block already registered?
$is_block_registered = \WP_Block_Type_Registry::get_instance()->is_registered( $videopress_video_block_name );
// Do not register if the block is already registered.
if ( $is_block_registered ) {
return;
}
$registration = register_block_type(
$videopress_video_metadata_file,
array(
'render_callback' => array( __CLASS__, 'render_videopress_video_block' ),
'render_email_callback' => array( Video_Block_Email_Renderer::class, 'render' ),
'uses_context' => array( 'premium-content/planId', 'isPremiumContentChild', 'selectedPlanId' ),
)
);
// Do not enqueue scripts if the block could not be registered.
if ( empty( $registration ) || empty( $registration->editor_script_handles ) ) {
return;
}
// Extensions use Connection_Initial_State::render_script with script handle as parameter.
if ( is_array( $registration->editor_script_handles ) ) {
$script_handle = $registration->editor_script_handles[0];
} else {
$script_handle = $registration->editor_script_handles;
}
// Register and enqueue scripts used by the VideoPress video block.
Block_Editor_Extensions::init( $script_handle );
}
/**
* Enqueue the VideoPress Iframe API script
* when the URL of oEmbed HTML is a VideoPress URL.
*
* @param string|false $cache The cached HTML result, stored in post meta.
* @param string $url The attempted embed URL.
* @param array $attr An array of shortcode attributes.
* @param int $post_ID Post ID.
*
* @return string|false
*/
public static function enqueue_videopress_iframe_api_script( $cache, $url, $attr, $post_ID ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( Utils::is_videopress_url( $url ) ) {
// Enqueue the VideoPress IFrame API in the front-end.
wp_enqueue_script(
self::JETPACK_VIDEOPRESS_IFRAME_API_HANDLER,
'https://s0.wp.com/wp-content/plugins/video/assets/js/videojs/videopress-iframe-api.js',
array(),
gmdate( 'YW' ),
false
);
}
return $cache;
}
}
@@ -0,0 +1,85 @@
<?php
/**
* VideoPress Jwt_Token_Bridge
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
/**
* VideoPress Jwt_Token_Bridge class.
*/
class Jwt_Token_Bridge {
/**
* The handle used to enqueue the script
*
* @var string
*/
const SCRIPT_HANDLE = 'media-video-jwt-bridge';
/**
* Initializer
*
* This method should be called only once by the Initializer class. Do not call this method again.
*/
public static function init() {
if ( ! Status::is_active() ) {
return;
}
// Expose the VideoPress token to the Block Editor context.
add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'enqueue_jwt_token_bridge' ), 1 );
// Expose the VideoPress token to the WPAdmin context.
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_jwt_token_bridge' ), 1 );
}
/**
* Gets the bridge script URL depending on the environment we are in
*
* @return string
*/
public static function get_bridge_url() {
return plugins_url( '../build/lib/token-bridge.js', __FILE__ );
}
/**
* Enqueues the jwt bridge script.
*/
public static function enqueue_jwt_token_bridge() {
global $post;
$post_id = isset( $post->ID ) ? absint( $post->ID ) : 0;
$bridge_url = self::get_bridge_url();
self::enqueue_script();
wp_localize_script(
self::SCRIPT_HANDLE,
'videopressAjax',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'bridgeUrl' => $bridge_url,
'post_id' => $post_id,
)
);
}
/**
* Enqueues only the JS script
*
* @param string $handle The script handle to identify the script.
* @return bool True if the script was successfully localized, false otherwise.
*/
public static function enqueue_script( $handle = self::SCRIPT_HANDLE ) {
return wp_enqueue_script(
$handle,
self::get_bridge_url(),
array(),
Package_Version::PACKAGE_VERSION,
false
);
}
}
@@ -0,0 +1,37 @@
<?php
/**
* Jetpack VideoPress: Module_Control class
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
/**
* To handle VideoPress module statuses
*/
class Module_Control {
/**
* Initializer
*
* This method should onlybe called once by the Initializer class. Do not call this method again.
*/
public static function init() {
add_filter( 'jetpack_get_available_standalone_modules', array( __CLASS__, 'add_videopress_to_array' ), 10, 1 );
if ( Status::is_standalone_plugin_active() ) {
// If the stand-alone plugin is active, videopress module will always be considered active
add_filter( 'jetpack_active_modules', array( __CLASS__, 'add_videopress_to_array' ), 10, 2 );
}
}
/**
* Adds videopress to the list of available/active modules
*
* @param array $modules Array with modules slugs.
* @return array
*/
public static function add_videopress_to_array( $modules ) {
return array_merge( array( 'videopress' ), $modules );
}
}
@@ -0,0 +1,83 @@
<?php
/**
* VideoPress Options
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\Current_Plan;
use Jetpack_Options;
/**
* VideoPress Options class.
*/
class Options {
/**
* Option name.
*
* @var string $option_name The 'videopress' option name
*/
public static $option_name = 'videopress';
/**
* VideoPress Options.
*
* @var array $options An array of associated VideoPress options (default empty)
*/
protected static $options = array();
/**
* Get VideoPress options
*
* @return array An array of VideoPress options.
*/
public static function get_options() {
// Make sure we only get options from the database and services once per connection.
if ( array() !== self::$options ) {
return self::$options;
}
$defaults = array(
'meta' => array(
'max_upload_size' => 0,
),
);
self::$options = Jetpack_Options::get_option( self::$option_name, array() );
self::$options = array_merge( $defaults, self::$options );
// Make sure that the shadow blog id never comes from the options, but instead uses the
// associated shadow blog id, if videopress is enabled.
self::$options['shadow_blog_id'] = 0;
// Use the Jetpack ID for the shadow blog ID if we have a plan that supports VideoPress.
if ( Current_Plan::supports( 'videopress' ) ) {
self::$options['shadow_blog_id'] = Jetpack_Options::get_option( 'id' );
}
return self::$options;
}
/**
* Update VideoPress options
*
* @param mixed $options VideoPress options.
*/
public static function update_options( $options ) {
Jetpack_Options::update_option( self::$option_name, $options );
self::$options = $options;
}
/**
* Runs when the VideoPress module is deactivated.
*/
public static function delete_options() {
Jetpack_Options::delete_option( self::$option_name );
self::$options = array();
}
}
@@ -0,0 +1,29 @@
<?php
/**
* The Package_Version class.
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
/**
* The Package_Version class.
*/
class Package_Version {
const PACKAGE_VERSION = '0.38.2';
const PACKAGE_SLUG = 'videopress';
/**
* Adds the package slug and version to the package version tracker's data.
*
* @param array $package_versions The package version array.
*
* @return array The packge version array.
*/
public static function send_package_version_to_tracker( $package_versions ) {
$package_versions[ self::PACKAGE_SLUG ] = self::PACKAGE_VERSION;
return $package_versions;
}
}
@@ -0,0 +1,183 @@
<?php
/**
* The Plan class.
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
/**
* The Plan class.
*/
class Plan {
/**
* The meta name used to store the cache date
*
* @var string
*/
const CACHE_DATE_META_NAME = 'videopress-cache-date';
/**
* Valid pediord for the cache: One week.
*/
const CACHE_VALIDITY_PERIOD = 7 * DAY_IN_SECONDS;
/**
* The meta name used to store the cache
*
* @var string
*/
const CACHE_META_NAME = 'videopress-cache';
/**
* Checks if the cache is old, meaning we need to fetch new data from WPCOM
*/
private static function is_cache_old() {
if ( empty( self::get_product_from_cache() ) ) {
return true;
}
$cache_date = get_user_meta( get_current_user_id(), self::CACHE_DATE_META_NAME, true );
return time() - (int) $cache_date > ( self::CACHE_VALIDITY_PERIOD );
}
/**
* Gets the product list from the user cache
*/
private static function get_product_from_cache() {
return get_user_meta( get_current_user_id(), self::CACHE_META_NAME, true );
}
/**
* Gets the product data
*
* @return array
*/
public static function get_product() {
$request_url = 'https://public-api.wordpress.com/rest/v1.1/products?locale=' . get_user_locale() . '&type=jetpack';
$wpcom_request = wp_remote_get( esc_url_raw( $request_url ) );
$response_code = wp_remote_retrieve_response_code( $wpcom_request );
if ( 200 === $response_code ) {
$products = json_decode( wp_remote_retrieve_body( $wpcom_request ) );
if ( ! isset( $products->jetpack_videopress ) || ! isset( $products->jetpack_videopress_monthly ) ) {
return array();
}
// Pick the desired product...
$product = $products->jetpack_videopress;
// ... and store it into the cache.
update_user_meta( get_current_user_id(), self::CACHE_DATE_META_NAME, time() );
update_user_meta( get_current_user_id(), self::CACHE_META_NAME, $product );
return $product;
}
return new \WP_Error(
'failed_to_fetch_data',
esc_html__( 'Unable to fetch the requested data.', 'jetpack-videopress-pkg' ),
array(
'status' => $response_code,
'request' => $wpcom_request,
)
);
}
/**
* Populate the pricing array with the discount information.
*
* @param object $product - The product object.
* @return int|false Discount percentage.
*/
public static function get_coupon_discount( $product ) {
// Check whether the product has a coupon.
if ( ! isset( $product->sale_coupon ) ) {
return false;
}
$product_id = $product->product_id;
$coupon = $product->sale_coupon;
// Check product is covered by the coupon.
if ( ! in_array( $product_id, $coupon->product_ids, true ) ) {
return false;
}
// Check whether it is still valid.
$coupon_start_date = strtotime( $coupon->start_date );
$coupon_expires = strtotime( $coupon->expires );
if ( $coupon_start_date > time() || $coupon_expires < time() ) {
return false;
}
if ( ! isset( $coupon->discount ) ) {
return false;
}
return intval( $coupon->discount );
}
/**
* Return details about the VideoPress product price
*
* @return array Produce price details
*/
public static function get_product_price() {
$request_url = 'https://public-api.wordpress.com/rest/v1.1/products?locale=' . get_user_locale() . '&type=jetpack';
$wpcom_request = wp_remote_get( esc_url_raw( $request_url ) );
$response_code = wp_remote_retrieve_response_code( $wpcom_request );
if ( 200 === $response_code ) {
$products = json_decode( wp_remote_retrieve_body( $wpcom_request ) );
$products_list = array();
if ( isset( $products->jetpack_videopress ) ) {
$videopress_yearly = $products->jetpack_videopress;
// get_coupon_discount
$products_list['yearly'] = array(
'name' => $videopress_yearly->product_name,
'slug' => $videopress_yearly->product_slug,
'price' => $videopress_yearly->cost,
'priceByMonth' => round( $videopress_yearly->cost / 12, 2 ),
'currency' => $videopress_yearly->currency_code,
);
$discount = self::get_coupon_discount( $videopress_yearly );
if ( $discount ) {
$products_list['yearly']['discount'] = $discount;
$products_list['yearly']['salePrice'] = round( $videopress_yearly->cost * ( 1 - $discount / 100 ), 2 );
$products_list['yearly']['salePriceByMonth'] = round( ( $videopress_yearly->cost * ( 1 - $discount / 100 ) / 12 ), 2 );
} else {
$products_list['yearly']['salePrice'] = $videopress_yearly->cost;
$products_list['yearly']['salePriceByMonth'] = round( $videopress_yearly->cost / 12, 2 );
}
}
if ( isset( $products->jetpack_videopress_monthly ) ) {
$videopress_monthly = $products->jetpack_videopress_monthly;
$products_list['monthly'] = array(
'name' => $videopress_monthly->product_name,
'slug' => $videopress_monthly->product_slug,
'price' => $videopress_monthly->cost,
'currency' => $videopress_monthly->currency_code,
);
}
return $products_list;
}
return new \WP_Error(
'failed_to_fetch_data',
esc_html__( 'Unable to fetch the requested data.', 'jetpack-videopress-pkg' ),
array(
'status' => $response_code,
'request' => $wpcom_request,
)
);
}
}
@@ -0,0 +1,169 @@
<?php
/**
* The VideoPress REST Controller.
*
* Registers the `/jetpack/v4/videopress/*` routes backing the
* modernized wp-build dashboard. Currently exposes one route — a
* user-signed proxy to the WPCOM `sites/{id}/stats/video-plays`
* endpoint — needed by the Overview screen's KPI / trends / top-N
* cards.
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\Connection\Client;
use Jetpack_Options;
use WP_Error;
use WP_REST_Request;
use WP_REST_Server;
/**
* REST routes for the modernized VideoPress admin UI.
*/
class Rest_Controller {
/**
* REST namespace used by this package's modernization routes.
*
* @var string
*/
const REST_NAMESPACE = 'jetpack/v4/videopress';
/**
* Hook the route registration on `rest_api_init`.
*
* @return void
*/
public static function init() {
add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
}
/**
* Register the VideoPress REST routes.
*
* @return void
*/
public static function register_rest_routes() {
register_rest_route(
self::REST_NAMESPACE,
'/stats/video-plays',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( __CLASS__, 'get_stats_video_plays' ),
'permission_callback' => array( __CLASS__, 'permissions_callback' ),
'args' => self::stats_video_plays_args(),
)
);
}
/**
* Query params accepted by the video-plays proxy. Forwarded verbatim
* to WPCOM after permission and shape validation; `complete_stats` is
* always forced to `true` (the only mode the Overview cares about)
* and is therefore not exposed as an incoming param.
*
* @return array
*/
private static function stats_video_plays_args() {
return array(
'period' => array(
'description' => __( 'Period unit: day, week, month, or year.', 'jetpack-videopress-pkg' ),
'type' => 'string',
'enum' => array( 'day', 'week', 'month', 'year' ),
),
'num' => array(
'description' => __( 'Number of periods to include.', 'jetpack-videopress-pkg' ),
'type' => 'integer',
'minimum' => 1,
'maximum' => 365,
),
'date' => array(
'description' => __( 'Most recent day to include in results (YYYY-MM-DD).', 'jetpack-videopress-pkg' ),
'type' => 'string',
'format' => 'date',
),
'start_date' => array(
'description' => __( 'Starting date for range queries (YYYY-MM-DD).', 'jetpack-videopress-pkg' ),
'type' => 'string',
'format' => 'date',
),
);
}
/**
* Permission callback. Admin-gated. The upstream call is blog-signed,
* matching the existing `Stats::fetch_video_plays` path; no user-level
* WPCOM connection is required.
*
* @return bool
*/
public static function permissions_callback() {
return current_user_can( 'manage_options' );
}
/**
* Proxy the video-plays stats endpoint.
*
* Forwards the whitelisted query params to WPCOM (REST v1.1, blog-signed
* — matching the existing `Stats::fetch_video_plays` path) and forces
* `complete_stats=true`. In complete-stats mode, each day entry carries
* `total.views`, `total.impressions`, and `total.watch_time` (in hours)
* plus a per-video `data[]` array whose entries have `post_id`, `title`,
* `views`, `impressions`, `watch_time` (hours), and `retention_rate`.
* The `plays` field is NOT returned in complete-stats mode.
*
* @param WP_REST_Request $request Incoming request.
* @return mixed Decoded JSON response from WPCOM, or WP_Error on failure.
*/
public static function get_stats_video_plays( WP_REST_Request $request ) {
$blog_id = (int) Jetpack_Options::get_option( 'id' );
if ( ! $blog_id ) {
return new WP_Error(
'videopress_stats_not_connected',
esc_html__( 'This site is not connected to WordPress.com.', 'jetpack-videopress-pkg' ),
array( 'status' => 400 )
);
}
$params = array( 'complete_stats' => 'true' );
foreach ( array_keys( self::stats_video_plays_args() ) as $key ) {
$value = $request->get_param( $key );
if ( $value !== null && $value !== '' ) {
$params[ $key ] = $value;
}
}
$path = sprintf(
'sites/%d/stats/video-plays?%s',
$blog_id,
http_build_query( $params )
);
$response = Client::wpcom_json_api_request_as_blog( $path );
if ( is_wp_error( $response ) ) {
return new WP_Error(
'videopress_stats_request_failed',
$response->get_error_message(),
array( 'status' => 500 )
);
}
$status = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== $status ) {
$message = is_array( $body ) && isset( $body['message'] )
? (string) $body['message']
: esc_html__( 'Unable to fetch VideoPress stats.', 'jetpack-videopress-pkg' );
return new WP_Error(
'videopress_stats_request_failed',
$message,
array( 'status' => $status ? $status : 500 )
);
}
return rest_ensure_response( $body );
}
}
@@ -0,0 +1,68 @@
<?php
/**
* Provides site data sourced from WPCOM
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\Connection\Client;
use WP_Error;
/**
* Provides site data sourced from WPCOM
*/
class Site {
/**
* Returns all the data provided by WPCOM for the site.
*
* @return int|WP_Error the total of plays for today, or WP_Error on failure.
*/
public static function get_site_info() {
$error = new WP_Error(
'videopress_site_error',
__( 'Could not fetch site information from the service', 'jetpack-videopress-pkg' )
);
$request_path = sprintf( 'sites/%d?force=wpcom', Data::get_blog_id() );
$response = Client::wpcom_json_api_request_as_blog( $request_path, '1.1', array(), null, 'rest' );
if ( is_wp_error( $response ) ) {
return $error;
}
$response_code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $response_code ) {
return $error;
}
$body = wp_remote_retrieve_body( $response );
return json_decode( $body, true );
}
/**
* Returns all the purchases provided by WPCOM for the site.
*
* @return array the list of purchases, or an empty list on failure.
*/
public static function get_purchases() {
$request_path = sprintf( 'sites/%1$d/purchases?locale=%2$s', Data::get_blog_id(), get_user_locale() );
$response = Client::wpcom_json_api_request_as_blog( $request_path, '1.1', array(), null, 'rest' );
if ( is_wp_error( $response ) ) {
return array();
}
$response_code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $response_code ) {
return array();
}
$body = wp_remote_retrieve_body( $response );
return json_decode( $body, true );
}
}
@@ -0,0 +1,189 @@
<?php
/**
* Provides data stats about videos inside VideoPress
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\Connection\Client;
use WP_Error;
/**
* Provides data stats about videos inside VideoPress
*/
class Stats {
/**
* Hit WPCOM video-plays stats endpoint.
*
* @param array $args Request args.
* @return array|WP_Error WP HTTP response on success
*/
public static function fetch_video_plays( $args = array() ) {
$blog_id = VideoPressToken::blog_id();
$endpoint = sprintf(
'sites/%d/stats/video-plays?check_stats_module=false',
$blog_id
);
if ( is_array( $args ) && ! empty( $args ) ) {
$endpoint .= '&' . http_build_query( $args );
}
$result = Client::wpcom_json_api_request_as_blog( $endpoint );
if ( is_wp_error( $result ) ) {
return $result;
}
$response = $result['http_response'];
$response_code = $response->get_status();
$response_body = json_decode( $response->get_data(), true );
if ( 200 !== $response_code ) {
return new WP_Error(
'videopress_stats_error',
$response_body
);
}
return array(
'code' => $response_code,
'data' => $response_body,
);
}
/**
* Returns the counter of today's plays for all videos.
*
* @return int|WP_Error the total of plays for today, or WP_Error on failure.
*/
public static function get_today_plays() {
$response = self::fetch_video_plays();
if ( is_wp_error( $response ) ) {
return $response;
}
$data = $response['data'];
if ( ! $data || ! isset( $data['days'] ) || ! is_countable( $data['days'] ) || count( $data['days'] ) === 0 ) {
return new WP_Error(
'videopress_stats_error',
__( 'Could not find any stats from the service', 'jetpack-videopress-pkg' )
);
}
/*
* The only result here is today's stats
*/
$today_stats = array_pop( $data['days'] );
return $today_stats['total_plays'];
}
/**
* Returns the featured stats for VideoPress.
*
* @param int $period_count (optional) The number of days to consider.
* @param string $period (optional) The period to consider.
*
* @return array|WP_Error a list of stats, or WP_Error on failure.
*/
public static function get_featured_stats( $period_count = 14, $period = 'day' ) {
$response = self::fetch_video_plays(
array(
'period' => $period,
'num' => $period_count,
'complete_stats' => true,
)
);
if ( is_wp_error( $response ) ) {
return $response;
}
$data = $response['data'];
if ( ! $data ) {
return new WP_Error(
'videopress_stats_error',
__( 'Could not find any stats from the service', 'jetpack-videopress-pkg' )
);
}
// Get only the list of dates
$dates = $data['days'];
// Organize the data into the planned stats
return self::prepare_featured_stats( $dates, $period_count, $period );
}
/**
* Prepares the featured stats for VideoPress.
*
* @param array $dates The list of dates returned by the API.
* @param int $period_count The total number of days to consider.
* @param string $period The period to consider.
* @return array a list of stats.
*/
public static function prepare_featured_stats( $dates, $period_count, $period = 'day' ) {
/**
* Ensure the sorting of the dates, recent ones first.
* This way, the first 7 positions are from the last 7 days,
* and the next 7 positions are from the 7 days before it.
*/
krsort( $dates );
$period_of_data = floor( $period_count / 2 );
$period = $period === 'day' ? __( 'day', 'jetpack-videopress-pkg' ) : __( 'year', 'jetpack-videopress-pkg' );
// template for the response
$featured_stats = array(
// translators: %1$d is the number of units of time, %2$s is the period in which the units of time are measured ex. 'day' or 'year'.
'label' => sprintf( _n( 'last %1$d %2$s', 'last %1$d %2$ss', (int) $period_of_data, 'jetpack-videopress-pkg' ), $period_of_data, $period ),
'period' => $period,
'data' => array(
'views' => array(
'current' => 0,
'previous' => 0,
),
'impressions' => array(
'current' => 0,
'previous' => 0,
),
'watch_time' => array(
'current' => 0,
'previous' => 0,
),
),
);
// Go through the dates to compute the stats
$counter = 0;
foreach ( $dates as $date_info ) {
$date_totals = $date_info['total'];
if ( $counter < floor( $period_count / 2 ) ) {
// the first 7 elements are for the current period
$featured_stats['data']['views']['current'] += $date_totals['views'];
$featured_stats['data']['impressions']['current'] += $date_totals['impressions'];
$featured_stats['data']['watch_time']['current'] += $date_totals['watch_time'];
} else {
// the next 7 elements are for the previous period
$featured_stats['data']['views']['previous'] += $date_totals['views'];
$featured_stats['data']['impressions']['previous'] += $date_totals['impressions'];
$featured_stats['data']['watch_time']['previous'] += $date_totals['watch_time'];
}
++$counter;
}
return $featured_stats;
}
}
@@ -0,0 +1,75 @@
<?php
/**
* The class that provides information about VideoPress Status
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\Modules;
/**
* The class that provides information about VideoPress Status
*/
class Status {
/**
* Returns whether VideoPress is active
* either as a Jetpack module or as a stand alone plugin
*
* @return boolean
*/
public static function is_active() {
return self::is_jetpack_plugin_and_videopress_module_active() || self::is_standalone_plugin_active();
}
/**
* Checks whether the Jetpack plugin is active
*/
public static function is_jetpack_plugin_active() {
return class_exists( 'Jetpack' );
}
/**
* Checks whether the Jetpack plugin
* and its VideoPress module are active.
*
* @return boolean
*/
public static function is_jetpack_plugin_and_videopress_module_active() {
return class_exists( 'Jetpack' ) && ( new Modules() )->is_active( 'videopress' );
}
/**
* Checks whether the Jetpack plugin is active
* but the VideoPress module is not active.
*
* @since 0.28.2
*
* @return boolean
*/
public static function is_jetpack_plugin_without_videopress_module_active() {
return class_exists( 'Jetpack' ) && ! ( new Modules() )->is_active( 'videopress' );
}
/**
* Checks whether the VideoPress stand alone plugin is active
*
* @return boolean
*/
public static function is_standalone_plugin_active() {
return class_exists( 'Jetpack_VideoPress_Plugin' );
}
/**
* Checks whether the registrant plugin is active
* either as a Jetpack module (via Jetpack plugin)
* or as a stand-alone plugin.
*
* @return boolean True if the register plugin is active.
*/
public static function is_registrant_plugin_active() {
return self::is_jetpack_plugin_active() || self::is_standalone_plugin_active();
}
}
@@ -0,0 +1,18 @@
<?php
/**
* VideoPress Uploader Exception
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
/**
* VideoPress Uploader Exception class
*/
class Upload_Exception extends \Exception {
const ERROR_INVALID_ATTACHMENT_ID = 0;
const ERROR_FILE_NOT_FOUND = 1;
const ERROR_MIME_TYPE_NOT_SUPPORTED = 2;
const ERROR_MALFORMED_FILE = 3;
}
@@ -0,0 +1,124 @@
<?php
/**
* VideoPress Uploader
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use WP_Error;
/**
* VideoPress Uploader class
*
* Handles the upload from the Media Library to VideoPress servers
*/
class Uploader_Rest_Endpoints {
/**
* Initializes the endpoints
*
* @return void
*/
public static function init() {
add_action( 'rest_api_init', array( __CLASS__, 'register_rest_endpoints' ) );
}
/**
* Register the REST API routes.
*
* @return void
*/
public static function register_rest_endpoints() {
$id_arg = array(
'description' => __( 'The ID of the attachment you want to upload to VideoPress', 'jetpack-videopress-pkg' ),
'type' => 'integer',
'required' => true,
'validate_callback' => __CLASS__ . '::validate_attachment_id',
);
register_rest_route(
'videopress/v1',
'upload/(?P<attachment_id>\d+)',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => __CLASS__ . '::check_status',
'permission_callback' => __CLASS__ . '::permissions_callback',
'args' => array(
'attachment_id' => $id_arg,
),
),
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => __CLASS__ . '::do_upload',
'permission_callback' => __CLASS__ . '::permissions_callback',
'args' => array(
'attachment_id' => $id_arg,
),
),
)
);
}
/**
* Checks wether the user have permission to perform the upload
*
* @return boolean
*/
public static function permissions_callback() {
return current_user_can( 'upload_files' );
}
/**
* Validates the attachment ID argument
*
* @param integer|string $value The attachment ID passed as an argument to the endpoint.
* @return boolean|WP_Error
*/
public static function validate_attachment_id( $value ) {
return Uploader::is_valid_attachment_id( $value );
}
/**
* Endpoint callback for the GET method. Checks the upload status
*
* @param \WP_REST_Request $request The request object.
* @return array|WP_Error
*/
public static function check_status( $request ) {
$attachment_id = $request->get_param( 'attachment_id' );
try {
$uploader = new Uploader( $attachment_id );
$status = $uploader->check_status();
return rest_ensure_response( $status );
} catch ( Upload_Exception $e ) {
return new WP_Error(
'rest_invalid_param',
$e->getMessage(),
array( 'status' => 400 )
);
}
}
/**
* Endpoint callback for the POST method. Uploads the video
*
* @param \WP_REST_Request $request The request object.
* @return array|WP_Error
*/
public static function do_upload( $request ) {
$attachment_id = $request->get_param( 'attachment_id' );
try {
$uploader = new Uploader( $attachment_id );
$status = $uploader->upload();
return rest_ensure_response( $status );
} catch ( Upload_Exception $e ) {
return new WP_Error(
'rest_invalid_param',
$e->getMessage(),
array( 'status' => 400 )
);
}
}
}
@@ -0,0 +1,300 @@
<?php
/**
* VideoPress Uploader
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use Jetpack_Options;
use VideoPressUploader\File_Exception;
use VideoPressUploader\Tus_Client;
/**
* VideoPress Uploader class
*
* Handles the upload from the Media Library to VideoPress servers
*/
class Uploader {
/**
* The key of the post meta that holds the ID of the attachment that holds the VideoPress video, in case this attachment was uploaded before.
*
* @var string
*/
const UPLOADED_KEY = '_videopress_uploaded_id';
/**
* The chunk size of each upload step
*
* @var int
*/
const CHUNK_SIZE = 5000000;
/**
* The Tus Client instance
*
* @var Tus_Client
*/
protected $client = null;
/**
* The attachment ID
*
* @var int
*/
protected $attachment_id;
/**
* Checks whether this feature is supported by the server
*
* @param int $attachment_id The ID of the video attachment we want to upload to VideoPress.
* @return boolean
*/
public static function is_valid_attachment_id( $attachment_id ) {
$file_path = get_attached_file( $attachment_id );
if ( ! $file_path || ! is_readable( $file_path ) ) {
return false;
}
if ( ! str_starts_with( get_post_mime_type( $attachment_id ), 'video/' ) ) {
return false;
}
return true;
}
/**
* Constructs the object
*
* @throws Upload_Exception If attachment is invalid or server does not support it.
* @param int $attachment_id The ID of the video attachment we want to upload to VideoPress.
*/
public function __construct( $attachment_id ) {
$this->attachment_id = $attachment_id;
if ( ! $this->get_file_path() ) {
throw new Upload_Exception( __( 'Invalid attachment ID', 'jetpack-videopress-pkg' ), Upload_Exception::ERROR_INVALID_ATTACHMENT_ID );
}
if ( ! is_readable( $this->get_file_path() ) ) {
throw new Upload_Exception( __( 'File not found', 'jetpack-videopress-pkg' ), Upload_Exception::ERROR_FILE_NOT_FOUND );
}
if ( ! $this->file_has_supported_mime_type() ) {
throw new Upload_Exception( __( 'Mime type not supported', 'jetpack-videopress-pkg' ), Upload_Exception::ERROR_MIME_TYPE_NOT_SUPPORTED );
}
}
/**
* Gets the path of the video file
*
* @return string
*/
public function get_file_path() {
return get_attached_file( $this->attachment_id );
}
/**
* Gets the mime type of the attachment
*
* @return string
*/
public function get_file_mime_type() {
return get_post_mime_type( $this->attachment_id );
}
/**
* Gets the name of the video file
*
* @return string
*/
public function get_file_name() {
return basename( $this->get_file_path() );
}
/**
* Gets the size of the video file
*
* @return int
*/
public function get_file_size() {
return filesize( $this->get_file_path() );
}
/**
* Checks if the mime type of the attachment is supported to be uploaded
*
* @return boolean
*/
public function file_has_supported_mime_type() {
return str_starts_with( $this->get_file_mime_type(), 'video/' );
}
/**
* Gets the VideoPress upload token
*
* @throws Upload_Exception If it fails to fetch the token.
*
* @return string
*/
public function get_upload_token() {
return VideoPressToken::videopress_upload_jwt();
}
/**
* Gets a unique upload key for this attachment
*
* @return string
*/
public function get_key() {
return sprintf( 's-%d-v-%d', Jetpack_Options::get_option( 'id' ), $this->attachment_id );
}
/**
* Sets the current attachment as uploaded and stores the ID of the VideoPress video attachment ID
*
* @param int $new_attachment_id The ID of the new attachment created to hold the VideoPress video.
* @return void
*/
protected function mark_as_uploaded( $new_attachment_id ) {
update_post_meta( $this->attachment_id, self::UPLOADED_KEY, $new_attachment_id );
}
/**
* Sets the current attachment as not being uploaded before. Deletes the reference to the videopress attachment
*
* @return void
*/
protected function unmark_as_uploaded() {
delete_post_meta( $this->attachment_id, self::UPLOADED_KEY );
}
/**
* Checks whether this attachment was uploaded before
*
* @return boolean
*/
public function is_uploaded() {
return ! empty( $this->get_uploaded_attachment_id() );
}
/**
* Gets the ID of the VideoPress video attachment in case this attachment was uploaded before
*
* @return boolean|string False if value is absent. Post ID on success.
*/
public function get_uploaded_attachment_id() {
return get_post_meta( $this->attachment_id, self::UPLOADED_KEY, true );
}
/**
* Retrieves the instance of the Tus_Client
*
* @return Tus_Client
*/
public function get_client() {
if ( $this->client !== null ) {
return $this->client;
}
$this->client = new Tus_Client( $this->get_key(), $this->get_upload_token(), Jetpack_Options::get_option( 'id' ) );
return $this->client;
}
/**
* Uploads a chunk of the file
*
* @return array With the status of the upload
*/
public function upload() {
if ( $this->is_uploaded() ) {
return $this->check_status();
}
try {
$this->get_client()->file( $this->get_file_path(), $this->get_file_name() );
$bytes_uploaded = $this->get_client()->upload( self::CHUNK_SIZE );
if ( $bytes_uploaded === $this->get_file_size() ) {
$this->mark_as_uploaded( $this->get_client()->get_uploaded_video_details()['media_id'] );
return array(
'status' => 'complete',
'bytes_uploaded' => $bytes_uploaded,
'file_size' => $this->get_file_size(),
'file_name' => $this->get_file_name(),
'upload_key' => $this->get_key(),
'uploaded_details' => $this->get_client()->get_uploaded_video_details(),
);
}
return array(
'status' => 'uploading',
'bytes_uploaded' => $bytes_uploaded,
'file_size' => $this->get_file_size(),
'file_name' => $this->get_file_name(),
'upload_key' => $this->get_key(),
);
} catch ( \Exception $e ) {
return array(
'status' => 'error',
'bytes_uploaded' => -1,
'file_size' => $this->get_file_size(),
'file_name' => $this->get_file_name(),
'upload_key' => $this->get_key(),
'error' => $e->getCode() . ': ' . $e->getMessage(),
);
}
}
/**
* Checks the status of the upload of this attachment
*
* @return array
*/
public function check_status() {
if ( $this->is_uploaded() ) {
$uploaded_attachment_id = $this->get_uploaded_attachment_id();
$uploaded_video_guid = get_post_meta( $uploaded_attachment_id, 'videopress_guid', true );
if ( $uploaded_video_guid ) {
return array(
'status' => 'uploaded',
'upload_key' => $this->get_key(),
'uploaded_post_id' => $uploaded_attachment_id,
'uploaded_video_guid' => $uploaded_video_guid,
);
} else {
// VideoPress attachment is gone, allow user to upload it again.
$this->unmark_as_uploaded();
}
}
try {
$offset = $this->get_client()->get_offset();
$status = false !== $offset ? 'resume' : 'new';
$offset = false === $offset ? 0 : $offset;
return array(
'status' => $status,
'bytes_uploaded' => $offset,
'file_size' => $this->get_file_size(),
'file_name' => $this->get_file_name(),
'upload_key' => $this->get_key(),
);
} catch ( File_Exception $e ) {
return array(
'status' => 'resume',
'bytes_uploaded' => 0,
'file_size' => $this->get_file_size(),
'file_name' => $this->get_file_name(),
'upload_key' => $this->get_key(),
);
} catch ( \Exception $e ) {
return array(
'status' => 'error',
'bytes_uploaded' => -1,
'file_size' => $this->get_file_size(),
'file_name' => $this->get_file_name(),
'message' => $e->getMessage(),
);
}
}
}
@@ -0,0 +1,96 @@
<?php
/**
* The Utils class.
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
/**
* The Utils class.
*/
class Utils {
/**
* Build a VideoPress video URL based on the guid and block attributes.
*
* @param string $guid - Video GUID.
* @param array $attributes - Video block attributes. Default is an empty array.
*
* @return string VideoPress video URL with the specified attributes.
*/
public static function get_video_press_url( $guid, $attributes = array() ) {
if ( ! $guid ) {
return null;
}
$video_press_url_options = wp_parse_args(
$attributes,
array(
'autoplay' => false,
'controls' => true,
'loop' => false,
'muted' => false,
'playsinline' => false,
'poster' => '',
'preload' => 'metadata',
'seekbarColor' => '',
'seekbarPlayedColor' => '',
'seekbarLoadingColor' => '',
'useAverageColor' => true,
)
);
$query_args = array(
'resizeToParent' => 1,
'cover' => 1,
'autoPlay' => (int) $video_press_url_options['autoplay'],
'controls' => (int) $video_press_url_options['controls'],
'loop' => (int) $video_press_url_options['loop'],
'muted' => (int) $video_press_url_options['muted'],
'persistVolume' => $video_press_url_options['muted'] ? 0 : 1,
'playsinline' => (int) $video_press_url_options['playsinline'],
'preloadContent' => $video_press_url_options['preload'],
'sbc' => $video_press_url_options['seekbarColor'],
'sbpc' => $video_press_url_options['seekbarPlayedColor'],
'sblc' => $video_press_url_options['seekbarLoadingColor'],
'useAverageColor' => (int) $video_press_url_options['useAverageColor'],
);
if ( ! empty( $video_press_url_options['poster'] ) ) {
$query_args['posterUrl'] = rawurlencode( $video_press_url_options['poster'] );
}
$url = 'https://videopress.com/v/' . $guid;
return add_query_arg( $query_args, $url );
}
/**
* Determines if a given URL is a VideoPress URL.
*
* @since x.x.x
*
* @param mixed $url The URL to check. Non-strings return false.
* @return bool True if the URL is a VideoPress URL, false otherwise.
*/
public static function is_videopress_url( $url ) {
return null !== self::extract_videopress_guid_from_url( $url );
}
/**
* Extract the VideoPress guid from a recognised VideoPress URL.
*
* @param mixed $url The URL to inspect. Non-strings return null.
* @return string|null The 8-character guid, or null if the URL is not a recognised VideoPress URL.
*/
public static function extract_videopress_guid_from_url( $url ) {
if ( ! is_string( $url ) ) {
return null;
}
$pattern = '/^https?:\/\/(?:(?:v(?:ideo)?\.wordpress\.com|videopress\.com)\/(?:v|embed)|v\.wordpress\.com|videos\.(?:videopress\.com|files\.wordpress\.com))\/([a-z\d]{8})(\/|\b)/i'; // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText
if ( preg_match( $pattern, $url, $matches ) ) {
return $matches[1];
}
return null;
}
}
@@ -0,0 +1,130 @@
<?php
/**
* VideoPress video block email renderer class.
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
/**
* Handles rendering VideoPress video blocks for email contexts.
*/
class Video_Block_Email_Renderer {
/**
* Render VideoPress video block for email.
*
* @param string $block_content The original block HTML content.
* @param array $parsed_block The parsed block data including attributes.
* @param object $rendering_context Email rendering context.
*
* @return string
*/
public static function render( $block_content, $parsed_block, $rendering_context ) {
// Validate input parameters and required dependencies.
if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ||
! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Embed' ) ) {
return '';
}
$attributes = $parsed_block['attrs'];
// Get the VideoPress URL from the guid attribute.
$videopress_url = self::get_videopress_url( $attributes );
if ( empty( $videopress_url ) ) {
return '';
}
// For private videos, render a simple link to the post since the video isn't accessible on VideoPress.
// The isPrivate attribute is pre-computed by the block editor based on video and site settings.
if ( isset( $attributes['isPrivate'] ) && true === $attributes['isPrivate'] ) {
return self::render_link( $parsed_block );
}
// Create a mock embed block structure that WooCommerce's embed renderer can handle.
// The embed renderer will detect VideoPress from the URL and render it appropriately.
$mock_embed_block = array(
'blockName' => 'core/embed',
'attrs' => array(
'url' => $videopress_url,
'providerNameSlug' => 'videopress',
),
'innerHTML' => sprintf(
'<figure class="wp-block-embed is-type-video is-provider-videopress"><div class="wp-block-embed__wrapper">%s</div></figure>',
esc_url( $videopress_url )
),
);
// Preserve email_attrs if present (used for spacing/width calculations).
if ( ! empty( $parsed_block['email_attrs'] ) ) {
$mock_embed_block['email_attrs'] = $parsed_block['email_attrs'];
}
// Use WooCommerce's core embed renderer.
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
$woo_embed_renderer = new \Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks\Embed();
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
return $woo_embed_renderer->render( $mock_embed_block['innerHTML'], $mock_embed_block, $rendering_context );
}
/**
* Render a simple link for private VideoPress videos in emails.
* Links to the post containing the video since private videos aren't accessible on VideoPress.
*
* @param array $parsed_block The parsed block data.
*
* @return string The rendered link HTML.
*/
private static function render_link( $parsed_block ) {
$post_url = get_the_permalink();
if ( empty( $post_url ) ) {
return '';
}
$link_text = __( 'Visit the post to watch the video', 'jetpack-videopress-pkg' );
$link_html = sprintf(
'<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
esc_url( $post_url ),
esc_html( $link_text )
);
// Wrap with spacing if email_attrs are present.
$email_attrs = $parsed_block['email_attrs'] ?? array();
if ( ! empty( $email_attrs['padding'] ) ) {
$link_html = sprintf(
'<p style="padding: %s;">%s</p>',
esc_attr( $email_attrs['padding'] ),
$link_html
);
}
return $link_html;
}
/**
* Get the VideoPress URL from block attributes for email rendering.
*
* @param array $attributes Block attributes.
*
* @return string VideoPress URL or empty string.
*/
private static function get_videopress_url( $attributes ) {
// Construct URL from guid attribute.
if ( ! empty( $attributes['guid'] ) ) {
$guid = $attributes['guid'];
// VideoPress guids are alphanumeric only (e.g., "nfbj0J36").
// Validate format to prevent any injection issues.
if ( preg_match( '/^[a-zA-Z0-9]+$/', $guid ) ) {
return 'https://videopress.com/v/' . $guid;
}
}
return '';
}
}
@@ -0,0 +1,79 @@
<?php
/**
* VideoPress Features Endpoint
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\My_Jetpack\Product;
use Automattic\Jetpack\Status\Host;
/**
* VideoPress REST API class for fetching site features from WPCOM.
*/
class VideoPress_Rest_Api_V1_Features {
/**
* Initializes the endpoints
*
* @return void
*/
public static function init() {
add_action( 'rest_api_init', array( static::class, 'register_rest_endpoints' ) );
}
/**
* Register the REST API routes.
*
* @return void
*/
public static function register_rest_endpoints() {
register_rest_route(
'videopress/v1',
'features',
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => static::class . '::get_features',
'permission_callback' => static::class . '::permissions_callback',
)
);
}
/**
* Checks whether the user has permissions to see the features.
*
* @return boolean
*/
public static function permissions_callback() {
return current_user_can( 'read' );
}
/**
* Returns VideoPress feature flags fetched fresh from WPCOM.
*
* Uses My Jetpack's Product::get_site_features_from_wpcom() which calls
* WPCOM /sites/%d/features API with a 15-second transient cache.
*
* @return \WP_REST_Response The response object.
*/
public static function get_features() {
$features = Product::get_site_features_from_wpcom();
if ( is_wp_error( $features ) ) {
return rest_ensure_response( $features );
}
$active = $features['active'] ?? array();
return rest_ensure_response(
array(
'isVideoPressSupported' => true, // Always true due to free tier.
// Check videopress-1tb-storage (Jetpack) or videopress (WordPress.com).
'isVideoPress1TBSupported' => in_array( 'videopress-1tb-storage', $active, true )
|| ( ( new Host() )->is_wpcom_platform() && in_array( 'videopress', $active, true ) ),
'isVideoPressUnlimitedSupported' => in_array( 'videopress-unlimited-storage', $active, true ),
)
);
}
}
@@ -0,0 +1,141 @@
<?php
/**
* VideoPress Settings Endpoint
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Rest API class for fetching and setting site settings related to VideoPress.
*/
class VideoPress_Rest_Api_V1_Settings {
/**
* Initializes the endpoints
*
* @return void
*/
public static function init() {
add_action( 'rest_api_init', array( static::class, 'register_rest_endpoints' ) );
}
/**
* Register the REST API routes.
*
* @return void
*/
public static function register_rest_endpoints() {
register_rest_route(
'videopress/v1',
'settings',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( static::class, 'get_settings' ),
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
),
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( static::class, 'update_settings' ),
'permission_callback' => function () {
return Data::can_perform_action() && current_user_can( 'manage_options' );
},
'args' => array(
'videopress_videos_private_for_site' => array(
'description' => __( 'If the VideoPress videos should be private by default', 'jetpack-videopress-pkg' ),
'type' => 'boolean',
'required' => true,
'sanitize_callback' => 'rest_sanitize_boolean',
),
),
),
)
);
}
/**
* Returns the value of the VideoPress settings.
*
* @return WP_Rest_Response - The response object.
*/
public static function get_settings() {
$has_connected_owner = Data::has_connected_owner();
if ( ! $has_connected_owner ) {
return rest_ensure_response(
new WP_Error(
'owner_not_connected',
'User not connected.',
array(
'code' => 503,
'connect_url' => Admin_UI::get_admin_page_url(),
)
)
);
}
$blog_id = Data::get_blog_id();
if ( ! $blog_id ) {
return rest_ensure_response(
new WP_Error( 'site_not_registered', 'Site not registered.', 503 )
);
}
$status = 200;
$data = Data::get_videopress_settings();
return rest_ensure_response(
new WP_REST_Response( $data, $status )
);
}
/**
* Updates the value of the VideoPress settings when a new value
* is present on the request body.
*
* @param WP_REST_Request $request the request object.
* @return WP_Rest_Response - The response object.
*/
public static function update_settings( $request ) {
$has_connected_owner = Data::has_connected_owner();
if ( ! $has_connected_owner ) {
return rest_ensure_response(
new WP_Error(
'owner_not_connected',
'User not connected.',
array(
'code' => 503,
'connect_url' => Admin_UI::get_admin_page_url(),
)
)
);
}
$blog_id = Data::get_blog_id();
if ( ! $blog_id ) {
return rest_ensure_response(
new WP_Error( 'site_not_registered', 'Site not registered.', 503 )
);
}
$json_params = $request->get_json_params();
// We are sure that the param is set because it's required by the request
update_option( 'videopress_private_enabled_for_site', boolval( $json_params['videopress_videos_private_for_site'] ) );
return rest_ensure_response(
array(
'code' => 'success',
'message' => __( 'VideoPress settings updated successfully.', 'jetpack-videopress-pkg' ),
'data' => 200,
)
);
}
}
@@ -0,0 +1,60 @@
<?php
/**
* VideoPress Site Info Endpoint
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use WP_REST_Response;
/**
* VideoPress rest api class for fetching site information
*/
class VideoPress_Rest_Api_V1_Site {
/**
* Initializes the endpoints
*
* @return void
*/
public static function init() {
add_action( 'rest_api_init', array( static::class, 'register_rest_endpoints' ) );
}
/**
* Register the REST API routes.
*
* @return void
*/
public static function register_rest_endpoints() {
register_rest_route(
'videopress/v1',
'site',
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => static::class . '::get_site_info',
'permission_callback' => static::class . '::permissions_callback',
)
);
}
/**
* Checks wether the user have permissions to see the site info
*
* @return boolean
*/
public static function permissions_callback() {
return current_user_can( 'read' ); // TODO: confirm this
}
/**
* Returns all the site information usually provided by Jetpack, without relying on Jetpack
*
* @return WP_Rest_Response The response object.
*/
public static function get_site_info() {
$data = Site::get_site_info();
return rest_ensure_response( $data );
}
}
@@ -0,0 +1,100 @@
<?php
/**
* VideoPress Stats Endpoints
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use WP_REST_Response;
/**
* VideoPress stats rest api class
*/
class VideoPress_Rest_Api_V1_Stats {
/**
* Initializes the endpoints
*
* @return void
*/
public static function init() {
add_action( 'rest_api_init', array( static::class, 'register_rest_endpoints' ) );
}
/**
* Register the REST API routes.
*
* @return void
*/
public static function register_rest_endpoints() {
/**
* Basic stats.
*/
register_rest_route(
'videopress/v1',
'stats',
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => static::class . '::get_stats',
'permission_callback' => static::class . '::permissions_callback',
)
);
/**
* Stats to be featured by My Jetpack.
*/
register_rest_route(
'videopress/v1',
'stats/featured',
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => static::class . '::get_featured_stats',
'permission_callback' => static::class . '::permissions_callback',
)
);
}
/**
* Checks wether the user have permissions to see stats
*
* @return boolean
*/
public static function permissions_callback() {
return current_user_can( 'manage_options' );
}
/**
* Endpoint for getting the general VideoPress stats for the site.
*
* Returns the plays for all the videos, for today and since the beggining of times.
*
* @return WP_Rest_Response - The response object.
*/
public static function get_stats() {
$today_plays = Stats::get_today_plays();
if ( is_wp_error( $today_plays ) ) {
// TODO: Improve status code.
return rest_ensure_response( $today_plays );
}
$data = array(
'plays' => array(
'today' => $today_plays,
),
);
return rest_ensure_response( $data );
}
/**
* Provides the stats that will be featured by My Jetpack.
*
* @return WP_Rest_Response - The response object.
*/
public static function get_featured_stats() {
$featured_stats = Stats::get_featured_stats();
return rest_ensure_response( $featured_stats );
}
}
@@ -0,0 +1,138 @@
<?php
/**
* VideoPress Token
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Jetpack_Options;
/**
* VideoPress token utility class
*/
class VideoPressToken {
/**
* Check if user is connected.
*
* @return bool
* @throws Upload_Exception - If user is not connected.
*/
private static function check_connection() {
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
return true;
}
if ( ! ( new Connection_Manager() )->has_connected_owner() ) {
throw new Upload_Exception( __( 'You need to connect Jetpack before being able to upload a video to VideoPress.', 'jetpack-videopress-pkg' ) );
}
return true;
}
/**
* Get current blog id.
*
* @return string - Blog id.
*/
public static function blog_id() {
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$blog_id = get_current_blog_id();
} else {
$blog_id = Jetpack_Options::get_option( 'id' );
}
return $blog_id;
}
/**
* Retrieve a Playback JWT via WPCOM api.
*
* @param string $guid The VideoPress GUID.
* @return string
*/
public static function videopress_playback_jwt( $guid ) {
$blog_id = static::blog_id();
$args = array(
'method' => 'POST',
);
$endpoint = "sites/{$blog_id}/media/videopress-playback-jwt/{$guid}";
$result = Client::wpcom_json_api_request_as_blog( $endpoint, 'v2', $args, null, 'wpcom' );
if ( is_wp_error( $result ) ) {
return $result;
}
$response = json_decode( $result['body'], true );
if ( empty( $response['metadata_token'] ) ) {
return false;
}
return $response['metadata_token'];
}
/**
* Retrieve a One Time Upload Token via WPCOM api.
*
* @return string
* @throws Upload_Exception If token is empty or is had an error.
*/
public static function videopress_onetime_upload_token() {
if ( static::check_connection() ) {
$blog_id = static::blog_id();
$args = array(
'method' => 'POST',
);
$endpoint = "sites/{$blog_id}/media/token";
$result = Client::wpcom_json_api_request_as_blog( $endpoint, Client::WPCOM_JSON_API_VERSION, $args );
if ( is_wp_error( $result ) ) {
throw new Upload_Exception( __( 'Could not obtain a VideoPress upload token. Please try again later.', 'jetpack-videopress-pkg' ) );
}
$response = json_decode( $result['body'], true );
if ( empty( $response['upload_token'] ) ) {
throw new Upload_Exception( __( 'Could not obtain a VideoPress upload token. Please try again later.', 'jetpack-videopress-pkg' ) );
}
return $response['upload_token'];
}
}
/**
* Gets the VideoPress Upload JWT
*
* @return string
* @throws Upload_Exception - If user is not connected, if token is empty or failed to obtain.
*/
public static function videopress_upload_jwt() {
if ( static::check_connection() ) {
$blog_id = static::blog_id();
$endpoint = "sites/{$blog_id}/media/videopress-upload-jwt";
$args = array( 'method' => 'POST' );
$result = Client::wpcom_json_api_request_as_blog( $endpoint, 'v2', $args, null, 'wpcom' );
if ( is_wp_error( $result ) ) {
throw new Upload_Exception(
__( 'Could not obtain a VideoPress upload JWT. Please try again later.', 'jetpack-videopress-pkg' ) .
'(' . $result->get_error_message() . ')'
);
}
$response = json_decode( $result['body'], true );
if ( empty( $response['upload_token'] ) ) {
throw new Upload_Exception( __( 'Could not obtain a VideoPress upload JWT. Please try again later. (empty upload token)', 'jetpack-videopress-pkg' ) );
}
return $response['upload_token'];
}
}
}
@@ -0,0 +1,170 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
/**
* Extend the REST API functionality for VideoPress users.
*
* @package automattic/jetpack-videopress
* @since-jetpack 7.1.0
* @since 0.3.0
*/
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\Connection\Manager as Jetpack_Connection;
use WP_Post;
use WP_REST_Request;
use WP_REST_Response;
/**
* Add per-attachment VideoPress data.
*
* { # Attachment Object
* ...
* jetpack_videopress_guid: (string) VideoPress identifier
* ...
* }
*
* @phan-constructor-used-for-side-effects
*/
class WPCOM_REST_API_V2_Attachment_VideoPress_Field {
/**
* The REST Object Type to which the jetpack_videopress_guid field will be added.
*
* @var string
*/
protected $object_type = 'attachment';
/**
* The name of the REST API field to add.
*
* @var string $field_name
*/
protected $field_name = 'jetpack_videopress_guid';
/**
* Constructor.
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_fields' ) );
// do this again later to collect any CPTs that get registered later.
add_action( 'restapi_theme_init', array( $this, 'register_fields' ), 20 );
}
/**
* Registers the jetpack_videopress field and adds a filter to remove it for attachments that are not videos.
*/
public function register_fields() {
global $wp_rest_additional_fields;
if ( ! empty( $wp_rest_additional_fields[ $this->object_type ][ $this->field_name ] ) ) {
return;
}
register_rest_field(
$this->object_type,
$this->field_name,
array(
'get_callback' => array( $this, 'get' ),
'update_callback' => null,
'schema' => $this->get_schema(),
)
);
add_filter( 'rest_prepare_attachment', array( $this, 'remove_field_for_non_videos' ), 10, 2 );
}
/**
* Defines data structure and what elements are visible in which contexts.
*
* @return array
*/
public function get_schema() {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => $this->field_name,
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'description' => __( 'Unique VideoPress ID', 'jetpack-videopress-pkg' ),
);
}
/**
* Getter: Retrieve current VideoPress data for a given attachment.
*
* @param array $attachment Response from the attachment endpoint.
* @param WP_REST_Request $request Request to the attachment endpoint.
*
* @return string
*/
public function get( $attachment, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( ! isset( $attachment['id'] ) ) {
return array();
}
$blog_id = Jetpack_Connection::get_site_id();
if ( ! is_int( $blog_id ) ) {
return array();
}
$videopress_guid = $this->get_videopress_guid( (int) $attachment['id'], $blog_id );
if ( ! $videopress_guid ) {
return '';
}
$schema = $this->get_schema();
$is_valid = rest_validate_value_from_schema( $videopress_guid, $schema, $this->field_name );
if ( is_wp_error( $is_valid ) ) {
return $is_valid;
}
return $videopress_guid;
}
/**
* Gets the VideoPress GUID for a given attachment.
*
* This is pulled out into a separate method to support unit test mocking.
*
* @param int $attachment_id Attachment ID.
* @param int $blog_id Blog ID.
*
* @return string
*/
public function get_videopress_guid( $attachment_id, $blog_id ) {
return video_get_info_by_blogpostid( $blog_id, $attachment_id )->guid ?? '';
}
/**
* Checks if the given attachment is a video.
*
* @param object $attachment The attachment object.
*
* @return false|int
*/
public function is_video( $attachment ) {
return wp_startswith( $attachment->post_mime_type, 'video/' );
}
/**
* Removes the jetpack_videopress_guid field from the response if the
* given attachment is not a video.
*
* @param WP_REST_Response $response Response from the attachment endpoint.
* @param WP_Post $attachment The original attachment object.
*
* @return mixed
*/
public function remove_field_for_non_videos( $response, $attachment ) {
if ( ! $this->is_video( $attachment ) ) {
unset( $response->data[ $this->field_name ] );
}
return $response;
}
}
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
wpcom_rest_api_v2_load_plugin( 'Automattic\Jetpack\VideoPress\WPCOM_REST_API_V2_Attachment_VideoPress_Field' );
}
@@ -0,0 +1,321 @@
<?php
/**
* Extend the REST API functionality for VideoPress users.
*
* @package automattic/jetpack-videopress
* @since-jetpack 7.1.0
* @since 0.3.1
*/
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\Connection\Manager as Jetpack_Connection;
use WP_Post;
use WP_REST_Request;
use WP_REST_Response;
/**
* Add per-attachment VideoPress data.
*
* { # Attachment Object
* ...
* jetpack_videopress: (object) VideoPress data
* ...
* }
*
* @since 7.1.0
*
* @phan-constructor-used-for-side-effects
*/
class WPCOM_REST_API_V2_Attachment_VideoPress_Data {
/**
* The REST Object Type to which the jetpack_videopress field will be added.
*
* @var string
*/
protected $object_type = 'attachment';
/**
* The name of the REST API field to add.
*
* @var string $field_name
*/
protected $field_name = 'jetpack_videopress';
/**
* Constructor.
*/
public function __construct() {
add_action( 'rest_api_init', array( $this, 'register_fields' ) );
if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
add_action( 'rest_api_init', array( $this, 'add_jetpack_videopress_custom_query_filters' ) );
}
// do this again later to collect any CPTs that get registered later.
add_action( 'restapi_theme_init', array( $this, 'register_fields' ), 20 );
}
/**
* Registers the jetpack_videopress field and adds a filter to remove it for attachments that are not videos.
*/
public function register_fields() {
global $wp_rest_additional_fields;
if ( ! empty( $wp_rest_additional_fields[ $this->object_type ][ $this->field_name ] ) ) {
return;
}
register_rest_field(
$this->object_type,
$this->field_name,
array(
'get_callback' => array( $this, 'get' ),
'update_callback' => null,
'schema' => $this->get_schema(),
)
);
add_filter( 'rest_prepare_attachment', array( $this, 'remove_field_for_non_videos' ), 10, 2 );
}
/**
* Adds the custom query filters
*/
public function add_jetpack_videopress_custom_query_filters() {
add_filter( 'rest_attachment_query', array( $this, 'filter_attachments_by_jetpack_videopress_fields' ), 999, 2 );
}
/**
* Filter request args to handle the custom VideoPress query filters
*
* Possible filters:
*
* `no_videopress`: the returned attachments should not have a videopress_guid
*
* @param array $args The original list of args before the filtering.
* @param WP_REST_Request $request The original request data.
*/
public function filter_attachments_by_jetpack_videopress_fields( $args, $request ) {
if ( ! isset( $args['meta_query'] ) || ! is_array( $args['meta_query'] ) ) {
$args['meta_query'] = array();
}
/* To ignore all VideoPress videos, select only attachments without videopress_guid meta field */
if ( isset( $request['no_videopress'] ) ) {
$args['meta_query'][] = array(
'key' => 'videopress_guid',
'compare' => 'NOT EXISTS',
);
}
/*
* Hide local attachments that have already been uploaded to VideoPress.
* Such "zombie" locals carry a `_videopress_uploaded_id` meta pointing
* at their VideoPress sibling attachment; the sibling is the row the
* dashboard should surface.
*/
if ( isset( $request['videopress_hide_already_uploaded'] ) ) {
$args['meta_query'][] = array(
'key' => Uploader::UPLOADED_KEY,
'compare' => 'NOT EXISTS',
);
}
/* Filter using privacy setting meta key */
if ( isset( $request['videopress_privacy_setting'] ) ) {
$videopress_privacy_setting = sanitize_text_field( $request['videopress_privacy_setting'] );
/* Allows the filtering to happens using a list of privacy settings separated by comma */
$videopress_privacy_setting_list = explode( ',', $videopress_privacy_setting );
$site_default_is_private = Data::get_videopress_videos_private_for_site();
if ( $site_default_is_private ) {
/**
* If the search is looking for private videos and the site default is private,
* the site default setting should be included on the search.
*/
if ( in_array( strval( \VIDEOPRESS_PRIVACY::IS_PRIVATE ), $videopress_privacy_setting_list, true ) ) {
$videopress_privacy_setting_list[] = \VIDEOPRESS_PRIVACY::SITE_DEFAULT;
}
} else { // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found
/**
* If the search is looking for public videos and the site default is public,
* the site default setting should be included on the search.
*/
if ( in_array( strval( \VIDEOPRESS_PRIVACY::IS_PUBLIC ), $videopress_privacy_setting_list, true ) ) {
$videopress_privacy_setting_list[] = \VIDEOPRESS_PRIVACY::SITE_DEFAULT;
}
}
$args['meta_query'][] = array(
'key' => 'videopress_privacy_setting',
'value' => $videopress_privacy_setting_list,
'compare' => 'IN',
);
}
/* Filter using rating meta key */
if ( isset( $request['videopress_rating'] ) ) {
$videopress_rating = sanitize_text_field( $request['videopress_rating'] );
/* Allows the filtering to happens using a list of ratings separated by comma */
$videopress_rating_list = explode( ',', $videopress_rating );
$args['meta_query'][] = array(
'key' => 'videopress_rating',
'value' => $videopress_rating_list,
'compare' => 'IN',
);
}
return $args;
}
/**
* Defines data structure and what elements are visible in which contexts
*/
public function get_schema() {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => $this->field_name,
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'description' => __( 'VideoPress Data', 'jetpack-videopress-pkg' ),
);
}
/**
* Getter: Retrieve current VideoPress data for a given attachment.
*
* @param array $attachment Response from the attachment endpoint.
* @param WP_REST_Request $request Request to the attachment endpoint.
*
* @return array
*/
public function get( $attachment, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( ! isset( $attachment['id'] ) ) {
return array();
}
$blog_id = Jetpack_Connection::get_site_id();
if ( ! is_int( $blog_id ) ) {
return array();
}
$videopress = $this->get_videopress_data( (int) $attachment['id'], $blog_id );
if ( ! $videopress ) {
return array();
}
return $videopress;
}
/**
* Gets the VideoPress GUID for a given attachment.
*
* This is pulled out into a separate method to support unit test mocking.
*
* @param int $attachment_id Attachment ID.
* @param int $blog_id Blog ID.
*
* @return array
*/
public function get_videopress_data( $attachment_id, $blog_id ) {
$info = video_get_info_by_blogpostid( $blog_id, $attachment_id );
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
$title = video_get_title( $blog_id, $attachment_id );
$description = video_get_description( $blog_id, $attachment_id );
$video_attachment = get_blog_post( $blog_id, $attachment_id );
if ( null === $video_attachment ) {
$caption = '';
} else {
$caption = $video_attachment->post_excerpt;
}
} else {
$title = $info->title;
$description = $info->description;
$caption = $info->caption;
}
$video_privacy_setting = ! isset( $info->privacy_setting ) ? \VIDEOPRESS_PRIVACY::SITE_DEFAULT : intval( $info->privacy_setting );
$private_enabled_for_site = Data::get_videopress_videos_private_for_site();
$is_private = $this->video_is_private( $video_privacy_setting, $private_enabled_for_site );
// The video needs a playback token if it's private for any reason (video privacy setting or site default privacy setting)
$video_needs_playback_token = $is_private;
return array(
'title' => $title,
'description' => $description,
'caption' => $caption,
'guid' => $info->guid ?? null,
'rating' => $info->rating ?? null,
'allow_download' =>
isset( $info->allow_download ) && $info->allow_download ? 1 : 0,
'display_embed' =>
isset( $info->display_embed ) && $info->display_embed ? 1 : 0,
'privacy_setting' => $video_privacy_setting,
'needs_playback_token' => $video_needs_playback_token,
'is_private' => $is_private,
'private_enabled_for_site' => $private_enabled_for_site,
);
}
/**
* Checks if the given attachment is a video.
*
* @param object $attachment The attachment object.
*
* @return false|int
*/
public function is_video( $attachment ) {
return isset( $attachment->post_mime_type ) && wp_startswith( $attachment->post_mime_type, 'video/' );
}
/**
* Removes the jetpack_videopress field from the response if the
* given attachment is not a video.
*
* @param WP_REST_Response $response Response from the attachment endpoint.
* @param WP_Post $attachment The original attachment object.
*
* @return mixed
*/
public function remove_field_for_non_videos( $response, $attachment ) {
if ( ! $this->is_video( $attachment ) ) {
unset( $response->data[ $this->field_name ] );
}
return $response;
}
/**
* Determines if a video is private based on the video privacy
* setting and the site default privacy setting.
*
* @param int $video_privacy_setting The privacy setting for the video.
* @param bool $private_enabled_for_site Flag stating if the default video privacy is private.
*
* @return bool
*/
private function video_is_private( $video_privacy_setting, $private_enabled_for_site ) {
if ( $video_privacy_setting === \VIDEOPRESS_PRIVACY::IS_PUBLIC ) {
return false;
}
if ( $video_privacy_setting === \VIDEOPRESS_PRIVACY::IS_PRIVATE ) {
return true;
}
return $private_enabled_for_site;
}
}
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
wpcom_rest_api_v2_load_plugin( 'Automattic\Jetpack\VideoPress\WPCOM_REST_API_V2_Attachment_VideoPress_Data' );
}
@@ -0,0 +1,605 @@
<?php
/**
* REST API endpoint for managing VideoPress metadata.
*
* @package automattic/jetpack
* @since-jetpack 9.3.0
* @since 0.1.3
*/
namespace Automattic\Jetpack\VideoPress;
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Constants;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* VideoPress wpcom api v2 endpoint
*
* @phan-constructor-used-for-side-effects
*/
class WPCOM_REST_API_V2_Endpoint_VideoPress extends WP_REST_Controller {
/**
* Constructor.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'videopress';
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register the route.
*/
public function register_routes() {
// Meta Route.
register_rest_route(
$this->namespace,
$this->rest_base . '/meta',
array(
'args' => array(
'id' => array(
'description' => __( 'The post id for the attachment.', 'jetpack-videopress-pkg' ),
'type' => 'integer',
'required' => true,
),
'title' => array(
'description' => __( 'The title of the video.', 'jetpack-videopress-pkg' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'description' => array(
'description' => __( 'The description of the video.', 'jetpack-videopress-pkg' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_textarea_field',
),
'caption' => array(
'description' => __( 'The caption of the video.', 'jetpack-videopress-pkg' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_textarea_field',
),
'rating' => array(
'description' => __( 'The video content rating. One of G, PG-13 or R-17', 'jetpack-videopress-pkg' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'display_embed' => array(
'description' => __( 'Display the share menu in the player.', 'jetpack-videopress-pkg' ),
'type' => 'boolean',
),
'allow_download' => array(
'description' => __( 'Display download option and allow viewers to download this video', 'jetpack-videopress-pkg' ),
'type' => 'boolean',
),
'privacy_setting' => array(
'description' => __( 'How to determine if the video should be public or private', 'jetpack-videopress-pkg' ),
'type' => 'integer',
'enum' => array(
\VIDEOPRESS_PRIVACY::IS_PUBLIC,
\VIDEOPRESS_PRIVACY::IS_PRIVATE,
\VIDEOPRESS_PRIVACY::SITE_DEFAULT,
),
),
),
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'videopress_block_update_meta' ),
'permission_callback' => function () {
return Data::can_perform_action() && current_user_can( 'edit_posts' );
},
)
);
// Poster Route.
register_rest_route(
$this->namespace,
$this->rest_base . '/(?P<video_guid>[A-Za-z0-9]{8})/poster',
array(
'args' => array(
'video_guid' => array(
'description' => __( 'The VideoPress GUID.', 'jetpack-videopress-pkg' ), // @phan-suppress-current-line PhanPluginMixedKeyNoKey
'type' => 'string',
'required' => true,
),
),
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'videopress_block_get_poster' ),
'permission_callback' => function () {
return current_user_can( 'read' );
},
),
array(
'args' => array(
'at_time' => array(
'description' => __( 'The time in the video to use as the poster frame.', 'jetpack-videopress-pkg' ),
'type' => 'integer',
),
'is_millisec' => array(
'description' => __( 'Whether the time is in milliseconds or seconds.', 'jetpack-videopress-pkg' ),
'type' => 'boolean',
),
'poster_attachment_id' => array(
'description' => __( 'The attachment id of the poster image.', 'jetpack-videopress-pkg' ),
'type' => 'integer',
),
),
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'videopress_block_update_poster' ),
'permission_callback' => function () {
return Data::can_perform_action() && current_user_can( 'upload_files' );
},
),
)
);
// Endpoint to know if the video metadata is editable.
register_rest_route(
$this->namespace,
$this->rest_base . '/(?P<video_guid>[A-Za-z0-9]{8})/check-ownership/(?P<post_id>\d+)/',
array(
'args' => array(
'video_guid' => array(
'description' => __( 'The VideoPress GUID.', 'jetpack-videopress-pkg' ), // @phan-suppress-current-line PhanPluginMixedKeyNoKey
'type' => 'string',
'required' => true,
),
'post_id' => array(
'description' => __( 'The post id for the attachment.', 'jetpack-videopress-pkg' ),
'type' => 'integer',
'required' => true,
),
),
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'videopress_video_belong_to_site' ),
'permission_callback' => function () {
return Data::can_perform_action() && current_user_can( 'upload_files' );
},
),
)
);
// Token Route.
register_rest_route(
$this->namespace,
$this->rest_base . '/upload-jwt',
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'videopress_upload_jwt' ),
'permission_callback' => function () {
return Data::can_perform_action() && current_user_can( 'upload_files' );
},
)
);
// Playback Token Route.
register_rest_route(
$this->namespace,
$this->rest_base . '/playback-jwt/(?P<video_guid>[A-Za-z0-9]{8})',
array(
'args' => array(
'video_guid' => array(
'description' => __( 'The VideoPress GUID.', 'jetpack-videopress-pkg' ),
'type' => 'string',
'required' => true,
),
),
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'videopress_playback_jwt' ),
'permission_callback' => function () {
return current_user_can( 'read' );
},
)
);
}
/**
* Check whether the video belongs to the current site,
* considering the given post_id and the video_guid.
*
* @param WP_REST_Request $request The request object.
* @return WP_REST_Response True if the video belongs to the current site, false otherwise.
*/
public function videopress_video_belong_to_site( $request ) {
$post_id = $request->get_param( 'post_id' );
$video_guid = $request->get_param( 'video_guid' );
if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
$found_guid = get_post_meta( $post_id, 'videopress_guid', true );
} else {
$blog_id = get_current_blog_id();
$info = video_get_info_by_blogpostid( $blog_id, $post_id );
$found_guid = $info ? $info->guid : '';
}
if ( ! $found_guid ) {
return rest_ensure_response( array( 'video-belong-to-site' => false ) );
}
return rest_ensure_response( array( 'video-belong-to-site' => $found_guid === $video_guid ) );
}
/**
* Hit WPCOM poster endpoint.
*
* @param string $video_guid The VideoPress GUID.
* @param array $args Request args.
* @param array $body Request body.
* @param string $query Request query.
* @return WP_REST_Response|WP_Error
*/
public function wpcom_poster_request( $video_guid, $args, $body = null, $query = '' ) {
$query = $query !== '' ? '?' . $query : '';
$endpoint = 'videos/' . $video_guid . '/poster' . $query;
$url = sprintf(
'%s/%s/v%s/%s',
Constants::get_constant( 'JETPACK__WPCOM_JSON_API_BASE' ),
'rest',
'1.1',
$endpoint
);
$request_args = array_merge( $args, array( 'body' => $body ) );
// @phan-suppress-next-line PhanAccessMethodInternal -- Phan is correct, but the usage is intentional.
$result = Client::_wp_remote_request( $url, $request_args );
if ( is_wp_error( $result ) ) {
return rest_ensure_response( $result );
}
$response = $result['http_response'];
$status = $response->get_status();
$data = array(
'code' => $status,
'data' => json_decode( $response->get_data(), true ),
);
return rest_ensure_response(
new WP_REST_Response( $data, $status )
);
}
/**
* Update the a poster image via the WPCOM REST API.
*
* @param WP_REST_Request $request The request object.
* @return WP_REST_Response|WP_Error
*/
public function videopress_block_update_poster( $request ) {
try {
$blog_id = VideoPressToken::blog_id();
$token = VideoPressToken::videopress_onetime_upload_token();
$video_guid = $request->get_param( 'video_guid' );
$json_params = $request->get_json_params();
$args = array(
'method' => 'POST',
'headers' => array(
'content-type' => 'application/json',
'Authorization' => 'X_UPLOAD_TOKEN token="' . $token . '" blog_id="' . $blog_id . '"',
),
);
return $this->wpcom_poster_request(
$video_guid,
$args,
wp_json_encode( $json_params, JSON_UNESCAPED_SLASHES )
);
} catch ( \Exception $e ) {
return rest_ensure_response( new WP_Error( 'videopress_block_update_poster_error', $e->getMessage() ) );
}
}
/**
* Retrieves a poster image via the WPCOM REST API.
*
* @param WP_REST_Request $request the request object.
* @return object|WP_Error Success object or WP_Error with error details.
*/
public function videopress_block_get_poster( $request ) {
$video_guid = $request->get_param( 'video_guid' );
$jwt = VideoPressToken::videopress_playback_jwt( $video_guid );
$args = array(
'method' => 'GET',
);
return $this->wpcom_poster_request(
$video_guid,
$args,
null,
'metadata_token=' . $jwt
);
}
/**
* Endpoint for getting the VideoPress Upload JWT
*
* @return WP_Rest_Response - The response object.
*/
public static function videopress_upload_jwt() {
$has_connected_owner = Data::has_connected_owner();
if ( ! $has_connected_owner ) {
return rest_ensure_response(
new WP_Error(
'owner_not_connected',
'User not connected.',
array(
'code' => 503,
'connect_url' => Admin_UI::get_admin_page_url(),
)
)
);
}
$blog_id = Data::get_blog_id();
if ( ! $blog_id ) {
return rest_ensure_response(
new WP_Error( 'site_not_registered', 'Site not registered.', 503 )
);
}
try {
$token = VideoPressToken::videopress_upload_jwt();
$status = 200;
$data = array(
'upload_token' => $token,
'upload_url' => videopress_make_resumable_upload_path( $blog_id ),
'upload_blog_id' => $blog_id,
);
} catch ( \Exception $e ) {
$status = 500;
$data = array(
'error' => $e->getMessage(),
);
}
return rest_ensure_response(
new WP_REST_Response( $data, $status )
);
}
/**
* Endpoint for generating a VideoPress Playback JWT
*
* @param WP_REST_Request $request the request object.
* @return WP_Rest_Response - The response object.
*/
public static function videopress_playback_jwt( $request ) {
$has_connected_owner = Data::has_connected_owner();
if ( ! $has_connected_owner ) {
return rest_ensure_response(
new WP_Error(
'owner_not_connected',
'User not connected.',
array(
'code' => 503,
'connect_url' => Admin_UI::get_admin_page_url(),
)
)
);
}
$blog_id = Data::get_blog_id();
if ( ! $blog_id ) {
return rest_ensure_response(
new WP_Error( 'site_not_registered', 'Site not registered.', 503 )
);
}
try {
$video_guid = $request->get_param( 'video_guid' );
$token = VideoPressToken::videopress_playback_jwt( $video_guid );
$status = 200;
$data = array(
'playback_token' => $token,
);
} catch ( \Exception $e ) {
$status = 500;
$data = array(
'error' => $e->getMessage(),
);
}
return rest_ensure_response(
new WP_REST_Response( $data, $status )
);
}
/**
* Updates attachment meta and video metadata via the WPCOM REST API.
*
* @param WP_REST_Request $request the request object.
* @return object|WP_Error Success object or WP_Error with error details.
*/
public function videopress_block_update_meta( $request ) {
$json_params = $request->get_json_params();
$post_id = $json_params['id'];
if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
$guid = get_post_meta( $post_id, 'videopress_guid', true );
} else {
$blog_id = get_current_blog_id();
$info = video_get_info_by_blogpostid( $blog_id, $post_id );
$guid = $info ? $info->guid : '';
}
if ( ! $guid ) {
return rest_ensure_response(
new WP_Error(
'error',
__( 'This attachment cannot be updated yet.', 'jetpack-videopress-pkg' )
)
);
}
$video_request_params = $json_params;
unset( $video_request_params['id'] );
$video_request_params['guid'] = $guid;
$endpoint = 'videos';
$args = array(
'method' => 'POST',
'headers' => array( 'content-type' => 'application/json' ),
);
$result = Client::wpcom_json_api_request_as_blog(
$endpoint,
'2',
$args,
wp_json_encode( $video_request_params, JSON_UNESCAPED_SLASHES ),
'wpcom'
);
if ( is_wp_error( $result ) ) {
return rest_ensure_response( $result );
}
$response_body = json_decode( wp_remote_retrieve_body( $result ) );
if ( is_bool( $response_body ) && $response_body ) {
/*
* Title, description and caption of the video are not stored as metadata on the attachment,
* but as post_content, post_title and post_excerpt on the attachment's post object.
* We need to update those fields here, too.
*/
$post_title = null;
if ( isset( $json_params['title'] ) ) {
$post_title = sanitize_text_field( $json_params['title'] );
wp_update_post(
array(
'ID' => $post_id,
'post_title' => $post_title,
)
);
}
$post_content = null;
if ( isset( $json_params['description'] ) ) {
$post_content = sanitize_textarea_field( $json_params['description'] );
wp_update_post(
array(
'ID' => $post_id,
'post_content' => $post_content,
)
);
}
$post_excerpt = null;
if ( isset( $json_params['caption'] ) ) {
$post_excerpt = sanitize_textarea_field( $json_params['caption'] );
wp_update_post(
array(
'ID' => $post_id,
'post_excerpt' => $post_excerpt,
)
);
}
// VideoPress data is stored in attachment meta for Jetpack sites, but not on wpcom.
if ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) {
$meta = wp_get_attachment_metadata( $post_id );
$should_update_meta = false;
if ( ! $meta ) {
return rest_ensure_response(
new WP_Error(
'error',
__( 'Attachment meta was not found.', 'jetpack-videopress-pkg' )
)
);
}
if ( isset( $json_params['display_embed'] ) && isset( $meta['videopress']['display_embed'] ) ) {
$meta['videopress']['display_embed'] = $json_params['display_embed'];
$should_update_meta = true;
}
if ( isset( $json_params['rating'] ) && isset( $meta['videopress']['rating'] ) && videopress_is_valid_video_rating( $json_params['rating'] ) ) {
$meta['videopress']['rating'] = $json_params['rating'];
$should_update_meta = true;
/** Set a new meta field so we can filter using it directly */
update_post_meta( $post_id, 'videopress_rating', $json_params['rating'] );
}
if ( isset( $json_params['title'] ) ) {
$meta['videopress']['title'] = $post_title;
$should_update_meta = true;
}
if ( isset( $json_params['description'] ) ) {
$meta['videopress']['description'] = $post_content;
$should_update_meta = true;
}
if ( isset( $json_params['caption'] ) ) {
$meta['videopress']['caption'] = $post_excerpt;
$should_update_meta = true;
}
if ( isset( $json_params['poster'] ) ) {
$meta['videopress']['poster'] = $json_params['poster'];
$should_update_meta = true;
}
if ( isset( $json_params['allow_download'] ) ) {
$allow_download = (bool) $json_params['allow_download'];
if ( ! isset( $meta['videopress']['allow_download'] ) || $meta['videopress']['allow_download'] !== $allow_download ) {
$meta['videopress']['allow_download'] = $allow_download;
$should_update_meta = true;
}
}
if ( isset( $json_params['privacy_setting'] ) ) {
$privacy_setting = $json_params['privacy_setting'];
if ( ! isset( $meta['videopress']['privacy_setting'] ) || $meta['videopress']['privacy_setting'] !== $privacy_setting ) {
$meta['videopress']['privacy_setting'] = $privacy_setting;
$should_update_meta = true;
/** Set a new meta field so we can filter using it directly */
update_post_meta( $post_id, 'videopress_privacy_setting', $privacy_setting );
}
}
if ( $should_update_meta ) {
wp_update_attachment_metadata( $post_id, $meta );
}
}
return rest_ensure_response(
array(
'code' => 'success',
'message' => __( 'Video meta updated successfully.', 'jetpack-videopress-pkg' ),
'data' => 200,
)
);
} else {
return rest_ensure_response(
new WP_Error(
$response_body->code,
$response_body->message,
$response_body->data
)
);
}
}
}
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
wpcom_rest_api_v2_load_plugin( 'Automattic\Jetpack\VideoPress\WPCOM_REST_API_V2_Endpoint_VideoPress' );
}
@@ -0,0 +1,220 @@
<?php
/**
* The VIdeoPress XMLRPC class
*
* @package automattic/jetpack-videopress
*/
namespace Automattic\Jetpack\VideoPress;
use WP_User;
/**
* VideoPress playback module markup generator.
*
* @since 0.1.1
*/
class XMLRPC {
/**
* Singleton XMLRPC instance.
*
* @var XMLRPC
**/
private static $instance = null;
/**
* The current user object.
*
* @var WP_User
*/
private $current_user;
/**
* Private VideoPress_XMLRPC constructor.
*
* Use the VideoPress_XMLRPC::init() method to get an instance.
*/
private function __construct() {
add_filter( 'jetpack_xmlrpc_methods', array( $this, 'xmlrpc_methods' ), 10, 3 );
}
/**
* Initialize the VideoPress_XMLRPC and get back a singleton instance.
*
* @return XMLRPC
*/
public static function init() {
if ( self::$instance === null ) {
self::$instance = new XMLRPC();
}
return self::$instance;
}
/**
* Adds additional methods the WordPress xmlrpc API for handling VideoPress specific features
*
* @param array $methods The Jetpack API methods.
* @param array $core_methods The WordPress Core API methods (ignored).
* @param WP_User $user The user object the API request is signed by.
*
* @return array
*/
public function xmlrpc_methods( $methods, $core_methods, $user ) {
if ( $user && $user instanceof WP_User ) {
$this->current_user = $user;
}
$methods['jetpack.createMediaItem'] = array( $this, 'create_media_item' );
$methods['jetpack.updateVideoPressMediaItem'] = array( $this, 'update_videopress_media_item' );
$methods['jetpack.updateVideoPressPosterImage'] = array( $this, 'update_poster_image' );
return $methods;
}
/**
* This is used by the WPCOM VideoPress uploader in order to create a media item with
* specific meta data about an uploaded file. After this, the transcoding session will
* update the meta information via the update_videopress_media_item() method.
*
* Note: This method technically handles the creation of multiple media objects, though
* in practice this is never done.
*
* @param array $media Media items being uploaded.
* @return array
*/
public function create_media_item( $media ) {
$this->authenticate_user();
foreach ( $media as & $media_item ) {
$title = sanitize_title( basename( $media_item['url'] ) );
$guid = $media['guid'] ?? null;
$media_id = videopress_create_new_media_item( $title, $guid );
wp_update_attachment_metadata(
$media_id,
array(
'original' => array(
'url' => $media_item['url'],
),
)
);
$media_item['post'] = get_post( $media_id );
}
return array( 'media' => $media );
}
/**
* Update VideoPress metadata for a media item.
*
* @param array $request Media item to update.
*
* @return bool
*/
public function update_videopress_media_item( $request ) {
$this->authenticate_user();
$id = $request['post_id'];
$status = $request['status'];
$format = $request['format'];
$info = $request['info'];
$attachment = get_post( $id );
if ( ! $attachment ) {
return false;
}
$attachment->guid = $info['original'];
$attachment->post_mime_type = 'video/videopress';
wp_update_post( $attachment );
// Update the vp guid and set it to a direct meta property.
update_post_meta( $id, 'videopress_guid', $info['guid'] );
$meta = wp_get_attachment_metadata( $id );
$meta['width'] = $info['width'];
$meta['height'] = $info['height'];
$meta['original']['url'] = $info['original'];
$meta['videopress'] = $info;
$meta['videopress']['url'] = 'https://videopress.com/v/' . $info['guid'];
// Update file statuses
if ( ! empty( $format ) ) {
$meta['file_statuses'][ $format ] = $status;
}
if ( ! get_post_meta( $id, '_thumbnail_id', true ) ) {
// Update the poster in the VideoPress info.
$thumbnail_id = videopress_download_poster_image( $info['poster'], $id );
if ( is_int( $thumbnail_id ) ) {
update_post_meta( $id, '_thumbnail_id', $thumbnail_id );
}
}
wp_update_attachment_metadata( $id, $meta );
videopress_update_meta_data( $id );
// update the meta to tell us that we're processing or complete
update_post_meta( $id, 'videopress_status', videopress_is_finished_processing( $id ) ? 'complete' : 'processing' );
return true;
}
/**
* Update poster image for a VideoPress media item.
*
* @param array $request The media item to update.
* @return bool
*/
public function update_poster_image( $request ) {
$this->authenticate_user();
$post_id = $request['post_id'];
$poster = $request['poster'];
$attachment = get_post( $post_id );
if ( ! $attachment ) {
return false;
}
$poster = apply_filters( 'jetpack_photon_url', $poster );
$meta = wp_get_attachment_metadata( $post_id );
$meta['videopress']['poster'] = $poster;
wp_update_attachment_metadata( $post_id, $meta );
// Update the poster in the VideoPress info.
$thumbnail_id = videopress_download_poster_image( $poster, $post_id );
if ( ! is_int( $thumbnail_id ) ) {
return false;
}
update_post_meta( $post_id, '_thumbnail_id', $thumbnail_id );
return true;
}
/**
* Check if the XML-RPC request is signed by a user token, and authenticate the user in WordPress.
*
* @return bool
*/
private function authenticate_user() {
if ( $this->current_user ) {
wp_set_current_user( $this->current_user->ID );
return true;
}
return false;
}
}
@@ -0,0 +1,99 @@
/* global jQuery, wp */
( function ( $, wp ) {
/**
* Returns attachment IDs for VideoPress videos that are still processing.
*
* @return {Array} Attachment IDs.
*/
function getProcessingVideoIds() {
if ( ! wp.media.frame || ! wp.media.frame.state() ) {
return [];
}
const library = wp.media.frame.state().get( 'library' );
if ( ! library ) {
return [];
}
const ids = [];
library.each( function ( attachment ) {
if (
attachment.get( 'type' ) === 'video' &&
attachment.get( 'subtype' ) === 'videopress' &&
attachment.get( 'videopress_status' ) &&
attachment.get( 'videopress_status' ) !== 'complete'
) {
ids.push( attachment.get( 'id' ) );
}
} );
return ids;
}
$( document ).on( 'heartbeat-send', function ( e, data ) {
const ids = getProcessingVideoIds();
if ( ids.length ) {
data.videopress_processing_ids = ids;
wp.heartbeat.interval( 'fast' );
} else {
wp.heartbeat.interval( 'standard' );
}
} );
// Speed up heartbeat when a video is uploaded so processing status
// is polled quickly. wp.Uploader.queue fires 'add' as soon as an
// upload starts, with the file's MIME type available.
if ( wp.Uploader ) {
wp.Uploader.queue.on( 'add', function ( attachment ) {
const file = attachment.get( 'file' );
if ( file && /^video\//.test( file.type ) ) {
wp.heartbeat.interval( 'fast' );
wp.heartbeat.connectNow();
}
} );
}
// Also kick off fast polling if videos were already processing on
// page load. The media frame isn't available immediately, so poll
// until it is.
const bootCheck = setInterval( function () {
if ( ! wp.media.frame || ! wp.media.frame.state() ) {
return;
}
clearInterval( bootCheck );
if ( getProcessingVideoIds().length ) {
wp.heartbeat.interval( 'fast' );
}
}, 500 );
$( document ).on( 'heartbeat-tick', function ( e, data ) {
if ( ! data.videopress_processing_status ) {
return;
}
if ( ! wp.media.frame || ! wp.media.frame.state() ) {
return;
}
const library = wp.media.frame.state().get( 'library' );
if ( ! library ) {
return;
}
$.each( data.videopress_processing_status, function ( id, status ) {
const attachment = library.get( id );
if ( attachment && status === 'complete' ) {
// Prevent duplicate fetches on subsequent ticks.
attachment.set( 'videopress_status', 'complete' );
wp.ajax
.send( 'get-attachment', {
data: { id: id },
} )
.done( function ( attrs ) {
attachment.set( attrs );
} );
}
} );
} );
} )( jQuery, wp );
@@ -0,0 +1,21 @@
<?php
/**
* Cap checker.
*
* @package VideoPressUploader
**/
namespace VideoPressUploader;
// Avoid direct calls to this file.
if ( ! defined( 'ABSPATH' ) ) {
die( 0 );
}
/**
* Unauthorized_Exception class.
*
* @package VideoPressUploader
**/
class File_Exception extends \Exception {
}
@@ -0,0 +1,69 @@
<?php
/**
* Transient Store.
*
* @package VideoPressUploader
**/
namespace VideoPressUploader;
// Avoid direct calls to this file.
if ( ! defined( 'ABSPATH' ) ) {
die( 0 );
}
/**
* Transient - based store.
*/
class Transient_Store extends Tus_Abstract_Cache {
/**
* Get key.
*
* @param string $key The blog_id.
*
* @return mixed|null
*/
public function cache_get( $key ) {
$contents = get_transient( $key );
return empty( $contents ) ? null : $contents;
}
/**
* Set cache key.
*
* @param string $key The key.
* @param array|mixed $value Even get the expired key.
* @param bool $is_update Is this an update.
*
* @return bool
*/
public function cache_set( $key, $value, $is_update = false ) {
if ( $is_update ) {
delete_transient( $key );
}
return set_transient( $key, $value, $this->get_ttl() );
}
/**
* Deletes a key.
*
* @param string $key The key.
*
* @return mixed
*/
public function cache_delete( $key ) {
return delete_transient( $key );
}
/**
* Get cache keys.
*
* @param string $prefix Prefix.
*
* @return mixed
*/
public function cache_keys( $prefix ) {
return get_transient( $prefix );
}
}
@@ -0,0 +1,282 @@
<?php
/**
* Main
*
* @package VideoPressUploader
**/
namespace VideoPressUploader;
// Avoid direct calls to this file.
if ( ! defined( 'ABSPATH' ) ) {
die( 0 );
}
/**
* Abstract cache class.
**/
abstract class Tus_Abstract_Cache {
/**
* Date format standard.
*
* @see https://tools.ietf.org/html/rfc7231#section-7.1.1.1
*
* @var int
**/
const RFC_7231 = 'D, d M Y H:i:s \G\M\T';
/**
* TTL in secs (default 1 day).
*
* @var int
**/
protected $ttl = 86400;
/** Prefix for cache keys.
*
* @var string
**/
protected $prefix = 'videopress_uploader_1_';
/**
* Various blog_id.
*
* @var string
*/
protected $blog_id = 'simple';
/**
* Cache constructor.
*
* @param int|string $blog_id The blog_id.
*/
public function __construct( $blog_id ) {
$this->blog_id = (string) $blog_id;
}
/**
* Cache Get key.
*
* @param string $key The blog_id.
*
* @return mixed|null
*/
abstract protected function cache_get( $key );
/**
* Set data to the given key.
*
* @param string $key The key.
* @param mixed $value The value.
* @param bool $is_update Is update.
*
* @return mixed
*/
abstract public function cache_set( $key, $value, $is_update );
/**
* Delete data associated with the key.
*
* @param string $key The key.
*
* @return bool
*/
abstract public function cache_delete( $key );
/**
* Get cache keys.
*
* @param string $keys_prefix The key prefix.
*
* @return mixed
*/
abstract public function cache_keys( $keys_prefix );
/**
* Build a cache key.
*
* @param string $key Key.
*
* @return string
*/
public function build_key( $key ) {
$prefix = $this->get_prefix();
if ( ! str_starts_with( $key, $prefix ) ) {
$key = $prefix . $key;
}
return $key;
}
/**
* Get key.
*
* @param string $key The blog_id.
* @param bool $with_expired Even get the expired keys.
*
* @return mixed|null
*/
public function get( $key, $with_expired = false ) {
$key = $this->build_key( $key );
$contents_str = $this->cache_get( $key );
$contents = json_decode( $contents_str, true );
if ( ! $contents ) {
return null;
}
if ( $with_expired ) {
return $contents;
}
return $this->is_content_expired( $contents ) ? null : $contents;
}
/**
* Deletes a key.
*
* @param string $key The key.
*
* @return mixed
*/
public function delete( $key ) {
$key = $this->build_key( $key );
return $this->cache_delete( $key ) > 0;
}
/**
* Set cache key.
*
* @param string $key The key.
* @param array|mixed $value Even get the expired key.
*
* @return bool
*/
public function set( $key, $value ) {
$key = $this->build_key( $key );
$cache_data = $this->get( $key );
$contents = $cache_data ? $cache_data : array();
if ( \is_array( $value ) ) {
$contents = array_merge( $contents, $value );
} else {
$contents[] = $value;
}
$status = $this->cache_set( $key, \wp_json_encode( $contents, JSON_UNESCAPED_SLASHES ), ! empty( $cache_data ) );
return false !== $status;
}
/**
* Get cache keys.
*
* @return mixed
*/
public function keys() {
// TODO: This needs more thought.
$keys_entry = $this->cache_keys( 'videopress_cache_keys_blog_' . $this->blog_id );
if ( is_string( $keys_entry ) ) {
return json_decode( $keys_entry );
}
return $keys_entry;
}
/**
* Delete all data associated with the keys.
*
* @param array $keys The keys to delete.
*
* @return bool
*/
public function delete_all( $keys ) {
$status = true;
foreach ( $keys as $key ) {
$r = $this->delete( $this->build_key( $key ) );
$status = $status && $r;
}
return $status;
}
/**
* Get time to live.
*
* @return int
*/
public function get_ttl() {
return $this->ttl;
}
/**
* Set time to live.
*
* @param int $secs The ttl.
*
* @return self
*/
public function set_ttl( $secs ) {
$this->ttl = $secs;
return $this;
}
/**
* Set cache prefix.
*
* @param string $prefix The prefix.
*
* @return self
*/
public function set_prefix( $prefix ) {
$this->prefix = $prefix;
return $this;
}
/**
* Get cache prefix.
*
* @return string
*/
public function get_prefix() {
/**
* Filters the cache prefix that will be used for VideoPress Uploader.
*
* @param string $cache_prefix The cache prefix.
*
* @return string
*/
return apply_filters( 'videopress_uploader_cache_prefix', $this->prefix );
}
/**
* Log stuff
*
* @param string $what Stuff to log.
*/
protected function log( $what ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
do_action( 'videopress_uploader_log', 'info', get_class( $this ) . ' Cache Store: \n\n' . print_r( $what, true ) );
}
/**
* Checks if content has an expires_at entry and compares to time().
*
* @param array $contents The contents.
*
* @return bool
**/
protected function is_content_expired( $contents ) {
if ( ! isset( $contents['expires_at'] ) ) {
return false;
}
$expires_at = $contents['expires_at'];
$date = Tus_Date_Utils::date_utc( $expires_at );
return absint( $date->getTimestamp() ) < time();
}
}
@@ -0,0 +1,814 @@
<?php
/**
* Tus_Client
*
* @package VideoPressUploader
**/
// phpcs:disable Generic.Commenting.DocComment.MissingShort
// phpcs:disable Squiz.Commenting.FunctionComment.EmptyThrows
// phpcs:disable Generic.Commenting.DocComment.MissingShort
// phpcs:disable Squiz.Commenting.FunctionComment.MissingParamComment
namespace VideoPressUploader;
use InvalidArgumentException;
use WP_Error;
use WP_Http;
/**
* Tus_Client
*/
class Tus_Client {
/** @const string Tus protocol version. */
const TUS_PROTOCOL_VERSION = '1.0.0';
/** @const string Upload type partial. */
const UPLOAD_TYPE_PARTIAL = 'partial';
/** @const string Upload type final. */
const UPLOAD_TYPE_FINAL = 'final';
/** @const string Name separator for partial upload. */
const PARTIAL_UPLOAD_NAME_SEPARATOR = '_';
/** @const string Upload type normal. */
const UPLOAD_TYPE_NORMAL = 'normal';
/** @const string Header Content Type */
const HEADER_CONTENT_TYPE = 'application/offset+octet-stream';
/** @const string Base API Uri */
const BASE_API_URL = 'https://public-api.wordpress.com/rest/v1.1/video-uploads/%d';
/** @var Tus_Abstract_Cache */
protected $cache;
/** @var string */
protected $file_path;
/** @var int */
protected $file_size = 0;
/** @var string */
protected $file_name;
/** @var string */
protected $key;
/** @var string */
protected $url;
/** @var string */
protected $checksum;
/** @var int */
protected $partial_offset = -1;
/** @var bool */
protected $partial = false;
/** @var string */
protected $checksum_algorithm = 'sha256';
/** @var array */
protected $metadata = array();
/** @var array */
protected $headers = array();
/**
* The details of the server response about the uploaded file
* VideoPress mod: Create new attribute
*
* @var array
*/
protected $uploaded_video_details = null;
/**
* The API url composed by BASE_API_URL and the Blod ID.
*
* @var string
*/
protected $api_url = null;
/**
* Tus_Client constructor.
*
* @param string $key The unique upload key identifier.
* @param string $upload_token The upload token retrieved from the server.
* @param int $blog_id The current Jetpack Blog ID.
*
* @throws \ReflectionException
* @throws InvalidArgumentException
*/
public function __construct( $key, $upload_token, $blog_id ) {
$this->key = $key;
$this->headers = array(
'Tus-Resumable' => self::TUS_PROTOCOL_VERSION,
'x-videopress-upload-token' => $upload_token,
);
$this->api_url = sprintf( self::BASE_API_URL, (int) $blog_id );
$this->cache = new Transient_Store( $this->get_key() );
}
/**
* Get cache.
*
* @return Tus_Abstract_Cache
*/
public function get_cache() {
return $this->cache;
}
/**
* Gets one attribute from the cache, if it exists.
*
* @param string $attribute The attribute name.
* @return mixed The attribute value if found or null.
*/
public function get_cache_attribute( $attribute ) {
$cache_values = $this->get_cache()->get( $this->get_key() );
return ! empty( $cache_values[ $attribute ] ) ? $cache_values[ $attribute ] : null;
}
/**
* Sets the uploaded video details
*
* @param string $guid The guid of the created video.
* @param string $media_id The ID of the attachment created.
* @param string $upload_src The video URL.
* @return void
*/
protected function set_uploaded_video_details( $guid, $media_id, $upload_src ) {
$this->uploaded_video_details = compact( 'guid', 'media_id', 'upload_src' );
}
/**
* Gets the details of the uploaded video
* VideoPress mod: Create new method
*
* @return array
*/
public function get_uploaded_video_details() {
return $this->uploaded_video_details;
}
/**
* Set file properties.
*
* @param string $file File path.
* @param string|null $name File name.
*
* @throws InvalidArgumentException
* @throws Tus_Exception
* @return Tus_Client
*/
public function file( $file, $name = null ) {
if ( ! is_string( $file ) ) {
throw new InvalidArgumentException( '$file needs to be a string' );
}
$this->file_path = $file;
if ( ! file_exists( $file ) || ! is_readable( $file ) ) {
throw new Tus_Exception( 'Cannot read file: ' . $file );
}
$this->file_name = ! empty( $name ) ? basename( $this->file_path ) : '';
$this->file_size = filesize( $file );
$this->add_metadata( 'filename', $this->file_name );
return $this;
}
/**
* Get file path.
*
* @return string|null
*/
public function get_file_path() {
return $this->file_path;
}
/**
* Set file name.
*
* @param string $name The file name.
*
* @throws InvalidArgumentException
* @return Tus_Client
*/
public function set_file_name( $name ) {
if ( ! is_string( $name ) ) {
throw new InvalidArgumentException( '$name needs to be a string' );
}
$this->add_metadata( 'filename', $this->file_name = $name );
return $this;
}
/**
* Get file name.
*
* @return string|null
*/
public function get_file_name() {
return $this->file_name;
}
/**
* Get file size.
*
* @return int
*/
public function get_file_size() {
return $this->file_size;
}
/**
* Set checksum.
*
* @param string $checksum
*
* @throws InvalidArgumentException
* @return Tus_Client
*/
public function set_checksum( $checksum ) {
if ( ! is_string( $checksum ) ) {
throw new InvalidArgumentException( '$checksum needs to be a string' );
}
$this->checksum = $checksum;
return $this;
}
/**
* Get checksum.
*
* @return string
*/
public function get_checksum() {
if ( empty( $this->checksum ) ) {
$this->set_checksum( hash_file( $this->get_checksum_algorithm(), $this->get_file_path() ) );
}
return $this->checksum;
}
/**
* Add metadata.
*
* @param string $key
* @param string $value
*
* @throws InvalidArgumentException
* @return Tus_Client
*/
public function add_metadata( $key, $value ) {
if ( ! is_string( $key ) || ! is_string( $value ) ) {
throw new InvalidArgumentException( '$key and $value need to be strings' );
}
$this->metadata[ $key ] = base64_encode( $value ); //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return $this;
}
/**
* Remove metadata.
*
* @param string $key
*
* @throws InvalidArgumentException
* @return Tus_Client
*/
public function remove_metadata( $key ) {
if ( ! is_string( $key ) ) {
throw new InvalidArgumentException( '$key needs to be a string' );
}
unset( $this->metadata[ $key ] );
return $this;
}
/**
* Set metadata.
*
* @param array $items
*
* @return Tus_Client
*/
public function set_metadata( array $items ) {
$items = array_map( 'base64_encode', $items );
$this->metadata = $items;
return $this;
}
/**
* Get metadata.
*
* @return array
*/
public function get_metadata() {
return $this->metadata;
}
/**
* Get metadata for Upload-Metadata header.
*
* @return string
*/
protected function get_upload_metadata_header() {
$metadata = array();
foreach ( $this->get_metadata() as $key => $value ) {
$metadata[] = "{$key} {$value}";
}
return implode( ',', $metadata );
}
/**
* Get key.
*
* @return string
*/
public function get_key() {
return $this->key;
}
/**
* Get url.
*
* @throws File_Exception
* @return string|null
*/
public function get_url() {
$this->url = $this->get_cache_attribute( 'location' );
if ( ! $this->url ) {
throw new File_Exception( 'File not found.' );
}
return $this->url;
}
/**
* Set checksum algorithm.
*
* @param string $algorithm
*
* @throws InvalidArgumentException
* @return Tus_Client
*/
public function set_checksum_algorithm( $algorithm ) {
if ( ! is_string( $algorithm ) ) {
throw new InvalidArgumentException( '$algorithm needs to be a string' );
}
$this->checksum_algorithm = $algorithm;
return $this;
}
/**
* Get checksum algorithm.
*
* @return string
*/
public function get_checksum_algorithm() {
return $this->checksum_algorithm;
}
/**
* Check if current upload is expired.
*
* @return bool
*/
public function is_expired() {
$expires_at = $this->get_cache_attribute( 'expires_at' );
return empty( $expires_at ) || time() > strtotime( $expires_at );
}
/**
* Check if this is a partial upload request.
*
* @return bool
*/
public function is_partial() {
return $this->partial;
}
/**
* Get partial offset.
*
* @return int
*/
public function get_partial_offset() {
return $this->partial_offset;
}
/**
* Set offset and force this to be a partial upload request.
*
* @param int $offset
*
* @throws InvalidArgumentException
* @return self
*/
public function seek( $offset ) {
if ( ! is_int( $offset ) ) {
throw new InvalidArgumentException( '$offset needs to be an integer' );
}
$this->partial_offset = $offset;
$this->partial();
return $this;
}
/**
* Upload file.
*
* @param int $bytes Bytes to upload.
*
* @throws Tus_Exception
* @throws InvalidArgumentException
*
* @return int
*/
public function upload( $bytes = -1 ) {
if ( ! is_int( $bytes ) ) {
throw new InvalidArgumentException( '$bytes needs to be an integer' );
}
$bytes = $bytes < 0 ? $this->get_file_size() : $bytes;
$partial_offset = $this->partial_offset < 0 ? 0 : $this->partial_offset;
$offset = $this->get_offset();
if ( is_wp_error( $offset ) ) {
throw new Tus_Exception( "Couldn't connect to server." );
}
if ( false === $offset ) {
$this->url = $this->create( $this->get_key() );
$offset = $partial_offset;
}
// Verify that upload is not yet expired.
if ( $this->is_expired() ) {
throw new Tus_Exception( 'Upload expired.' );
}
// Now, resume upload with PATCH request.
return $this->send_patch_request( $bytes, $offset );
}
/**
* Create resource with POST request.
*
* @param string $key
*
* @throws InvalidArgumentException
*
* @return string
*/
public function create( $key ) {
if ( ! is_string( $key ) ) {
throw new InvalidArgumentException( '$key needs to be a string' );
}
return $this->create_with_upload( $key, 0 )['location'];
}
/**
* Create resource with POST request and upload data using the creation-with-upload extension.
*
* @see https://tus.io/protocols/resumable-upload.html#creation-with-upload
*
* @param string $key
* @param int $bytes -1 => all data; 0 => no data.
*
* @throws InvalidArgumentException
* @throws Tus_Exception
*
* @return array [
* 'location' => string,
* 'offset' => int
* ]
*/
public function create_with_upload( $key, $bytes = -1 ) {
if ( ! is_string( $key ) ) {
throw new InvalidArgumentException( '$key needs to be a string' );
}
$bytes = $bytes < 0 ? $this->file_size : $bytes;
$headers = $this->headers + array(
'Upload-Length' => $this->file_size,
'Upload-Key' => $key,
'Upload-Checksum' => $this->get_upload_checksum_header(),
'Upload-Metadata' => $this->get_upload_metadata_header(),
);
$data = '';
if ( $bytes > 0 ) {
$data = $this->get_data( 0, $bytes );
$headers += array(
'Content-Type' => self::HEADER_CONTENT_TYPE,
'Content-Length' => \strlen( $data ),
);
}
if ( $this->is_partial() ) {
$headers += array( 'Upload-Concat' => 'partial' );
}
$response = $this->do_post_request(
$this->api_url,
array(
'body' => $data,
'headers' => $headers,
)
);
if ( is_wp_error( $response ) ) {
throw new Tus_Exception( 'Error reaching the server.' );
}
$status_code = wp_remote_retrieve_response_code( $response );
if ( WP_Http::CREATED !== $status_code ) {
$body = json_decode( wp_remote_retrieve_body( $response ) );
$error_message = __( 'Unable to create resource.', 'jetpack-videopress-pkg' );
if ( ! empty( $body->message ) ) {
$error_message = $body->message;
if ( ! empty( $body->error ) ) {
$error_message = $body->error . ': ' . $error_message;
}
}
// server can respond in a few different ways.
if ( isset( $body->success ) && false === $body->success ) {
if ( ! empty( $body->data ) && ! empty( $body->data->message ) ) {
$error_message = $body->data->message;
}
}
throw new Tus_Exception( $error_message, $status_code );
}
$upload_offset = $bytes > 0 ? wp_remote_retrieve_header( $response, 'upload-offset' ) : 0;
$upload_location = wp_remote_retrieve_header( $response, 'location' );
$cache = $this->get_cache();
$cache->set(
$this->get_key(),
array(
'location' => $upload_location,
'expires_at' => gmdate( $cache::RFC_7231, time() + $cache->get_ttl() ),
'token_for_key' => wp_remote_retrieve_header( $response, 'x-videopress-upload-key-token' ),
)
);
return array(
'location' => $upload_location,
'offset' => $upload_offset,
);
}
/**
* Set as partial request.
*
* @param bool $state
*
* @throws InvalidArgumentException
* @return void
*/
protected function partial( $state = true ) {
if ( ! is_bool( $state ) ) {
throw new InvalidArgumentException( '$state needs to be a boolean' );
}
$this->partial = $state;
if ( ! $this->partial ) {
return;
}
$key = $this->get_key();
if ( str_contains( $key, self::PARTIAL_UPLOAD_NAME_SEPARATOR ) ) {
list($key, /* $partialKey */) = explode( self::PARTIAL_UPLOAD_NAME_SEPARATOR, $key );
}
$this->key = $key . self::PARTIAL_UPLOAD_NAME_SEPARATOR . wp_generate_uuid4();
}
/**
* Send HEAD request and retrieves offset.
*
* @return bool|int|WP_Error integer with the offset uploaded if file exists. False if file does not exist. WP_Error on connection error.
*/
public function get_offset() {
$headers = $this->headers + array(
'X-HTTP-Method-Override' => 'HEAD',
);
try {
$response = $this->do_get_request(
$this->get_url(),
array( 'headers' => $headers )
);
} catch ( File_Exception $e ) {
return false;
}
if ( is_wp_error( $response ) ) {
return $response;
}
$response_code = wp_remote_retrieve_response_code( $response );
if ( WP_Http::OK !== $response_code ) {
return false;
}
return (int) wp_remote_retrieve_header( $response, 'upload-offset' );
}
/**
* Send PATCH request.
*
* @param int $bytes
* @param int $offset
*
* @throws InvalidArgumentException
*
* @return int
*/
protected function send_patch_request( $bytes, $offset ) {
if ( ! is_int( $bytes ) || ! is_int( $offset ) ) {
throw new InvalidArgumentException( '$bytes and $offset need to be integers' );
}
$data = $this->get_data( $offset, $bytes );
$headers = $this->headers + array(
'Content-Type' => self::HEADER_CONTENT_TYPE,
'Content-Length' => \strlen( $data ),
'Upload-Checksum' => $this->get_upload_checksum_header(),
'X-HTTP-Method-Override' => 'PATCH',
);
$token = $this->get_cache_attribute( 'token_for_key' );
$headers['x-videopress-upload-token'] = $token;
if ( $this->is_partial() ) {
$headers += array( 'Upload-Concat' => self::UPLOAD_TYPE_PARTIAL );
} else {
$headers += array( 'Upload-Offset' => $offset );
}
$response = $this->do_post_request(
$this->get_url(),
array(
'body' => $data,
'headers' => $headers,
)
);
$response_code = wp_remote_retrieve_response_code( $response );
if ( WP_Http::NO_CONTENT !== $response_code ) {
throw $this->handle_patch_exception( $response );
}
$guid = wp_remote_retrieve_header( $response, 'x-videopress-upload-guid' );
$media_id = (int) wp_remote_retrieve_header( $response, 'x-videopress-upload-media-id' );
$upload_src = wp_remote_retrieve_header( $response, 'x-videopress-upload-src-url' );
if ( $guid && $media_id && $upload_src ) {
$this->set_uploaded_video_details( $guid, $media_id, $upload_src );
}
return (int) wp_remote_retrieve_header( $response, 'upload-offset' );
}
/**
* Handle client exception during patch request.
*
* @param array $response The response from the PATCH request.
*
* @return \Exception
*/
protected function handle_patch_exception( $response ) {
if ( is_wp_error( $response ) ) {
return new Tus_Exception( $response->get_error_message() );
}
$response_code = wp_remote_retrieve_response_code( $response );
if ( WP_Http::REQUESTED_RANGE_NOT_SATISFIABLE === $response_code ) {
return new Tus_Exception( 'The uploaded file is corrupt.' );
}
if ( WP_Http::HTTP_CONTINUE === $response_code ) {
return new Tus_Exception( 'Connection aborted by user.' );
}
if ( WP_Http::UNSUPPORTED_MEDIA_TYPE === $response_code ) {
return new Tus_Exception( 'Unsupported media types.' );
}
return new Tus_Exception( (string) wp_remote_retrieve_body( $response ), $response_code );
}
/**
* Get X bytes of data from file.
*
* @param int $offset
* @param int $bytes
*
* @throws InvalidArgumentException
*
* @return string
*/
protected function get_data( $offset, $bytes ) {
if ( ! is_int( $bytes ) || ! is_int( $offset ) ) {
throw new InvalidArgumentException( '$bytes and $offset need to be integers' );
}
$file = new Tus_File();
$handle = $file->open( $this->get_file_path(), $file::READ_BINARY );
$file->seek( $handle, $offset );
$data = $file->read( $handle, $bytes );
$file->close( $handle );
return $data;
}
/**
* Get upload checksum header.
*
* @return string
*/
protected function get_upload_checksum_header() {
return $this->get_checksum_algorithm() . ' ' . base64_encode( $this->get_checksum() ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
}
/**
* Do HTTP request
*
* @param string $url The URL to make the request to.
* @param array $args Request arguments.
* @return array|WP_Error WordPress Http response
*/
protected function do_request( $url, $args ) {
$args = wp_parse_args(
$args,
array(
'timeout' => 25,
)
);
return wp_remote_request( $url, $args );
}
/**
* Do a GET HTTP request
*
* @param string $url The URL to make the request to.
* @param array $args Request arguments.
* @return array|WP_Error WordPress Http response
*/
protected function do_get_request( $url, $args ) {
$args = wp_parse_args(
$args,
array(
'method' => 'GET',
)
);
return $this->do_request( $url, $args );
}
/**
* Do a POST HTTP request
*
* @param string $url The URL to make the request to.
* @param array $args Request arguments.
* @return array|WP_Error WordPress Http response
*/
protected function do_post_request( $url, $args ) {
$args = wp_parse_args(
$args,
array(
'method' => 'POST',
)
);
return $this->do_request( $url, $args );
}
}
@@ -0,0 +1,43 @@
<?php
/**
* Tus_Date Utils.
*
* @package VideoPressUploader
**/
namespace VideoPressUploader;
// Avoid direct calls to this file.
if ( ! defined( 'ABSPATH' ) ) {
die( 0 );
}
/**
* Class Tus_Date_Utils
*/
class Tus_Date_Utils {
/**
* Returns a UTC date.
*
* @param null|int|string $s Thing to turn to date utc.
*
* @return \DateTimeImmutable
* @throws \Exception If the thing provided does not make sense.
*/
public static function date_utc( $s = null ) {
return new \DateTimeImmutable( $s, new \DateTimeZone( 'UTC' ) );
}
/**
* Adds seconds to a date.
*
* @param \DateTimeImmutable $date The date.
* @param int $seconds The seconds.
*
* @return \DateTimeImmutable
* @throws \Exception If invalid interval.
*/
public static function add_seconds( \DateTimeImmutable $date, $seconds ) {
return $date->add( new \DateInterval( 'PT' . absint( $seconds ) . 'S' ) );
}
}
@@ -0,0 +1,15 @@
<?php
/**
* Tus Exception
*
* @package jetpack-videopress
*/
namespace VideoPressUploader;
/**
* Tus Exception
*/
class Tus_Exception extends \Exception {
}
@@ -0,0 +1,684 @@
<?php
/**
* Main
*
* @package VideoPressUploader
**/
namespace VideoPressUploader;
use InvalidArgumentException;
// Avoid direct calls to this file.
if ( ! defined( 'ABSPATH' ) ) {
die( 0 );
}
/**
* Class Tus_File.
*
* @package VideoPressUploader
*/
class Tus_File {
const CHUNK_SIZE = 8192; // 8 kilobytes.
const INPUT_STREAM = 'php://input';
const READ_BINARY = 'rb';
const APPEND_BINARY = 'ab';
/**
* The input stream.
*
* @var string
*/
protected static $input_stream = self::INPUT_STREAM;
/**
* The key.
*
* @var string The key.
*/
protected $key;
/**
* The file checksum.
*
* @var string
*/
protected $checksum;
/**
* The file name.
*
* @var string
*/
protected $name;
/**
* The cache we are using.
*
* @var Tus_Abstract_Cache
*/
protected $cache;
/**
* The current file offset.
*
* @var int
*/
protected $offset;
/**
* The location.
*
* @var string
*/
protected $location;
/**
* The file path.
*
* @var string
*/
protected $file_path;
/**
* The file size.
*
* @var int
*/
protected $file_size;
/**
* The upload metadata.
*
* @var array
*/
private $upload_metadata = array();
/**
* File constructor.
*
* @param string|null $name Name.
* @param Tus_Abstract_Cache|null $cache Cache.
*/
public function __construct( $name = null, $cache = null ) {
$this->name = $name;
$this->cache = $cache;
}
/**
* Returns an integer if it's castable. Otherwise it throws
*
* @param string|int $number Number to cast.
* @throws InvalidArgumentException If argument is invalid.
* @return int
*/
public function ensure_integer( $number ) {
if ( ! is_numeric( $number ) ) {
throw new InvalidArgumentException( 'argument needs to be an integer. Check stacktrace' );
}
return (int) $number;
}
/**
* Set file meta.
*
* @param int $offset Offset.
* @param int $file_size File size.
* @param string $file_path File path.
* @param string $location Location.
*
* @throws InvalidArgumentException If argument is invalid.
* @return $this
*/
public function set_meta( $offset, $file_size, $file_path, $location = null ) {
$offset = $this->ensure_integer( $offset );
$file_size = $this->ensure_integer( $file_size );
if ( ! is_string( $file_path ) ) {
throw new InvalidArgumentException( '$file_path needs to be a string' );
}
$this->offset = absint( $offset );
$this->file_size = absint( $file_size );
$this->file_path = $file_path;
$this->location = $location;
return $this;
}
/**
* Set name.
*
* @param string $name Name.
*
* @throws InvalidArgumentException If argument is invalid.
*
* @return $this
*/
public function set_name( $name ) {
if ( ! is_string( $name ) ) {
throw new InvalidArgumentException( '$name needs to be a string' );
}
$this->name = $name;
return $this;
}
/**
* Get name.
*
* @return string
*/
public function get_name() {
return $this->name;
}
/**
* Set file size.
*
* @param int $size The size.
*
* @throws InvalidArgumentException If argument is invalid.
* @return Tus_File
*/
public function set_file_size( $size ) {
$size = $this->ensure_integer( $size );
$this->file_size = $size;
return $this;
}
/**
* Get file size.
*
* @return int
*/
public function get_file_size() {
return $this->file_size;
}
/**
* Set key.
*
* @param string $key The key.
*
* @throws InvalidArgumentException If argument is invalid.
* @return Tus_File
*/
public function set_key( $key ) {
if ( ! is_string( $key ) ) {
throw new InvalidArgumentException( '$key needs to be a string' );
}
$this->key = $key;
return $this;
}
/**
* Get key.
*
* @return string
*/
public function get_key() {
return $this->key;
}
/**
* Set checksum.
*
* @param string $checksum The checksum.
*
* @throws InvalidArgumentException If argument is invalid.
* @return Tus_File
*/
public function set_checksum( $checksum ) {
if ( ! is_string( $checksum ) ) {
throw new InvalidArgumentException( '$checksum needs to be a string' );
}
$this->checksum = $checksum;
return $this;
}
/**
* Get checksum.
*
* @return string
*/
public function get_checksum() {
return $this->checksum;
}
/**
* Set offset.
*
* @param int $offset The offset.
*
* @throws InvalidArgumentException If argument is invalid.
* @return self
*/
public function set_offset( $offset ) {
$offset = $this->ensure_integer( $offset );
$this->offset = absint( $offset );
return $this;
}
/**
* Get offset.
*
* @return int
*/
public function get_offset() {
return $this->offset;
}
/**
* Set location.
*
* @param string $location The location.
*
* @throws InvalidArgumentException If argument is invalid.
* @return self
*/
public function set_location( $location ) {
if ( ! is_string( $location ) ) {
throw new InvalidArgumentException( '$location needs to be a string' );
}
$this->location = $location;
return $this;
}
/**
* Get location.
*
* @return string
*/
public function get_location() {
return $this->location;
}
/**
* Set absolute file location.
*
* @param string $path The path.
*
* @return Tus_File
*/
public function set_file_path( $path ) {
$this->file_path = $path;
return $this;
}
/**
* Get absolute location.
*
* @return string
*/
public function get_file_path() {
return $this->file_path;
}
/**
* Set the upload meta.
*
* @param array $metadata The metadata.
*
* @return Tus_File
*/
public function set_upload_metadata( array $metadata ) {
$this->upload_metadata = $metadata;
return $this;
}
/**
* Get input stream.
*
* @return string
*/
public function get_input_stream() {
return self::$input_stream;
}
/**
* Set input stream. Useful for testing.
*
* @param string $stream The stream.
*
* @return void
*/
public static function set_input_stream( $stream ) {
self::$input_stream = $stream;
}
/**
* Get file meta.
*
* @return array
* @throws \Exception If date fails.
*/
public function details() {
$now = Tus_Date_Utils::date_utc();
$ttl = $this->cache->get_ttl();
return array(
'name' => $this->name,
'size' => $this->file_size,
'offset' => $this->offset,
'checksum' => $this->checksum,
'location' => $this->location,
'file_path' => $this->file_path,
'metadata' => $this->upload_metadata,
'created_at' => $now->format( Tus_Abstract_Cache::RFC_7231 ),
'expires_at' => Tus_Date_Utils::add_seconds( $now, $ttl )->format( Tus_Abstract_Cache::RFC_7231 ),
);
}
/**
* Upload file to server.
*
* @param int $total_bytes The total bytes of the file.
*
* @return int
* @throws \Out_Of_Range_Exception Various exceptions.
* @throws \Connection_Exception Various exceptions.
* @throws File_Exception Various exceptions.
*/
public function upload( $total_bytes ) {
if ( $this->offset === $total_bytes ) {
return $this->offset;
}
try {
$input = $this->open( $this->get_input_stream(), self::READ_BINARY );
$output = $this->open( $this->get_file_path(), self::APPEND_BINARY );
$key = $this->get_key();
} catch ( File_Exception $fe ) {
Logger::log( 'error', $fe );
throw new File_Exception( 'Upload failed.' );
}
try {
$this->seek( $output, $this->offset );
while ( ! feof( $input ) ) {
if ( CONNECTION_NORMAL !== connection_status() ) {
throw new \Connection_Exception( 'Connection aborted by user.' );
}
$data = $this->read( $input, self::CHUNK_SIZE );
$bytes = $this->write( $output, $data, self::CHUNK_SIZE );
$this->offset += $bytes;
if ( $this->offset > $total_bytes ) {
throw new \Out_Of_Range_Exception( 'The uploaded file is corrupt.' );
}
if ( $this->offset === $total_bytes ) {
break;
}
}
} finally {
$this->close( $input );
$this->close( $output );
try {
$this->cache->set( $key, array( 'offset' => $this->offset ) );
} catch ( \Throwable $e ) {
Logger::log( 'error', $e );
}
}
return $this->offset;
}
/**
* Open file in given mode.
*
* @param string $file_path The file path.
* @param string $mode The mode.
*
* @return resource
* @throws File_Exception Exc.
* @throws InvalidArgumentException If argument is invalid.
*/
public function open( $file_path, $mode ) {
if ( ! is_string( $file_path ) ) {
throw new InvalidArgumentException( '$file_path needs to be a string' );
}
if ( ! is_string( $mode ) ) {
throw new InvalidArgumentException( '$mode needs to be a string' );
}
$this->exists( $file_path, $mode );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged
$ptr = @fopen( $file_path, $mode );
if ( false === $ptr ) {
Logger::log( 'error', "Unable to open file at $file_path." );
throw new File_Exception( 'Unable to open file.' );
}
return $ptr;
}
/**
* Check if file to read exists.
*
* @param string $file_path The file path.
* @param string $mode The mode.
*
* @return bool
* @throws File_Exception File.
* @throws InvalidArgumentException If argument is invalid.
*/
public function exists( $file_path, $mode = self::READ_BINARY ) {
if ( ! is_string( $file_path ) ) {
throw new InvalidArgumentException( '$file_path needs to be a string' );
}
if ( self::INPUT_STREAM === $file_path ) {
return true;
}
if ( self::READ_BINARY === $mode && ! file_exists( $file_path ) ) {
throw new File_Exception( 'File not found.' );
}
return true;
}
/**
* Move file pointer to given offset using fseek.
*
* @param resource $handle The handle.
* @param int $offset The offset.
* @param int $whence The whence.
*
* @throws File_Exception Exc.
*
* @return int
*/
public function seek( $handle, $offset, $whence = SEEK_SET ) {
$offset = $this->ensure_integer( $offset );
$position = fseek( $handle, $offset, $whence );
if ( -1 === $position ) {
throw new File_Exception( 'Cannot move pointer to desired position.' );
}
return $position;
}
/**
* Read data from file.
*
* @param resource $handle The handle.
* @param int $chunk_size Chunk size.
*
* @return string
* @throws File_Exception If no data is read.
*/
public function read( $handle, $chunk_size ) {
$chunk_size = $this->ensure_integer( $chunk_size );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
$data = fread( $handle, $chunk_size );
if ( false === $data ) {
throw new File_Exception( 'Cannot read file.' );
}
return (string) $data;
}
/**
* Write data to file.
*
* @param resource $handle The file handle.
* @param string $data The data to write.
* @param int|null $length Possibly the length of the data.
*
* @throws File_Exception When can't write.
*
* @return int
*/
public function write( $handle, $data, $length = null ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
$bytes_written = \is_numeric( $length ) ? fwrite( $handle, $data, intval( $length ) ) : fwrite( $handle, $data );
if ( false === $bytes_written ) {
throw new File_Exception( 'Cannot write to a file.' );
}
return $bytes_written;
}
/**
* Merge 2 or more files.
*
* @param array $files File data with meta info.
*
* @return int
* @throws File_Exception When the file to be merged is not found.
*/
public function merge( array $files ) {
$destination = $this->get_file_path();
$first_file = array_shift( $files );
// First partial file can directly be copied.
$this->copy( $first_file['file_path'], $destination );
$this->offset = $first_file['offset'];
$this->file_size = filesize( $first_file['file_path'] );
$handle = $this->open( $destination, self::APPEND_BINARY );
foreach ( $files as $file ) {
if ( ! file_exists( $file['file_path'] ) ) {
throw new File_Exception( 'File to be merged not found.' );
}
$this->file_size += $this->write( $handle, $this->get_wp_filesystem()->get_contents( $file['file_path'] ) );
$this->offset += $file['offset'];
}
$this->close( $handle );
return $this->file_size;
}
/**
* Copy file from source to destination.
*
* @param string $source The source.
* @param string $destination The destination.
*
* @return bool
* @throws File_Exception If copy fails.
*/
public function copy( $source, $destination ) {
$status = copy( $source, $destination );
if ( ! $status ) {
Logger::log( 'error', sprintf( 'Cannot copy source (%s) to destination (%s).', $source, $destination ) );
throw new File_Exception( 'Cannot copy source file to destination file.' );
}
return $status;
}
/**
* Delete file and/or folder.
*
* @param array $files The files.
* @param bool $folder The folder.
*
* @return bool
*/
public function delete( array $files, $folder = false ) {
$status = $this->delete_files( $files );
if ( $status && $folder ) {
return $this->get_wp_filesystem()->rmdir( \dirname( current( $files ) ) );
}
return $status;
}
/**
* Delete multiple files.
*
* @param array $files The files.
*
* @return bool
*/
public function delete_files( array $files ) {
if ( empty( $files ) ) {
return false;
}
$status = true;
foreach ( $files as $file ) {
if ( $this->get_wp_filesystem()->exists( $file ) ) {
$r = $this->get_wp_filesystem()->delete( $file );
$status = $status && $r;
}
}
return $status;
}
/**
* Close file.
*
* @param mixed $handle The handle.
*
* @return bool
*/
public function close( $handle ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
return fclose( $handle );
}
/**
* Get the wp filesystem.
*
* @return \WP_Filesystem_Base|null
*/
private function get_wp_filesystem() {
global $wp_filesystem;
if ( ! isset( $wp_filesystem ) ) {
require_once ABSPATH . '/wp-admin/includes/file.php';
WP_Filesystem();
}
return $wp_filesystem;
}
}
@@ -0,0 +1,801 @@
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
use Automattic\Jetpack\Connection\Client;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
defined( 'VIDEOPRESS_MIN_WIDTH' ) || define( 'VIDEOPRESS_MIN_WIDTH', 60 );
defined( 'VIDEOPRESS_DEFAULT_WIDTH' ) || define( 'VIDEOPRESS_DEFAULT_WIDTH', 640 );
// phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- TODO: Move classes to appropriately-named class files.
/**
* VideoPress Privacy constants.
*/
abstract class VIDEOPRESS_PRIVACY {
const IS_PUBLIC = 0;
const IS_PRIVATE = 1;
const SITE_DEFAULT = 2;
}
/**
* Validate user-supplied guid values against expected inputs
*
* @since 1.1
* @param string $guid video identifier.
* @return bool true if passes validation test
*/
function videopress_is_valid_guid( $guid ) {
if ( ! empty( $guid ) && is_string( $guid ) && strlen( $guid ) === 8 && ctype_alnum( $guid ) ) {
return true;
}
return false;
}
/**
* Validates user-supplied video preload setting.
*
* @param mixed $value the preload value to validate.
* @return bool
*/
function videopress_is_valid_preload( $value ) {
return in_array( strtolower( $value ), array( 'auto', 'metadata', 'none' ), true );
}
/**
* Get details about a specific video by GUID:
*
* @param string $guid Video GUID.
* @return object
*/
function videopress_get_video_details( $guid ) {
if ( ! videopress_is_valid_guid( $guid ) ) {
return new WP_Error( 'bad-guid-format', __( 'Invalid Video GUID!', 'jetpack-videopress-pkg' ) );
}
$version = '1.1';
$endpoint = sprintf( '/videos/%1$s', $guid );
$query_url = sprintf(
'https://public-api.wordpress.com/rest/v%1$s%2$s',
$version,
$endpoint
);
// Look for data in our transient. If nothing, let's make a new query.
$data_from_cache = get_transient( 'jetpack_videopress_' . $guid );
if ( false === $data_from_cache ) {
$response = wp_remote_get( esc_url_raw( $query_url ) );
$data = json_decode( wp_remote_retrieve_body( $response ) );
// Cache the response for an hour.
set_transient( 'jetpack_videopress_' . $guid, $data, HOUR_IN_SECONDS );
} else {
$data = $data_from_cache;
}
/**
* Allow functions to modify fetched video details.
*
* This filter allows third-party code to modify the return data
* about a given video. It may involve swapping some data out or
* adding new parameters.
*
* @since 4.0.0
*
* @param object $data The data returned by the WPCOM API. See: https://developer.wordpress.com/docs/api/1.1/get/videos/%24guid/
* @param string $guid The GUID of the VideoPress video in question.
*/
return apply_filters( 'videopress_get_video_details', $data, $guid );
}
/**
* Similar to `media_sideload_image` -- but returns an ID.
*
* @param string $url Image URL.
* @param int $attachment_id Post ID.
*
* @return int|mixed|object|WP_Error
*/
function videopress_download_poster_image( $url, $attachment_id ) {
// Set variables for storage, fix file filename for query strings.
preg_match( '/[^\?]+\.(jpe?g|jpe|gif|png)\b/i', $url, $matches );
if ( ! $matches ) {
return new WP_Error( 'image_sideload_failed', __( 'Invalid image URL', 'jetpack-videopress-pkg' ) );
}
$file_array = array();
$file_array['name'] = basename( $matches[0] );
$file_array['tmp_name'] = download_url( $url );
// If error storing temporarily, return the error.
if ( is_wp_error( $file_array['tmp_name'] ) ) {
return $file_array['tmp_name'];
}
// Do the validation and storage stuff.
$thumbnail_id = media_handle_sideload( $file_array, $attachment_id, null );
// Flag it as poster image, so we can exclude it from display.
update_post_meta( $thumbnail_id, 'videopress_poster_image', 1 );
return $thumbnail_id;
}
/**
* Creates a local media library item of a remote VideoPress video.
*
* @param string $guid Video GUID.
* @param int $parent_id Parent post ID.
*
* @return int|object
*/
function create_local_media_library_for_videopress_guid( $guid, $parent_id = 0 ) {
$vp_data = videopress_get_video_details( $guid );
if ( ! $vp_data || is_wp_error( $vp_data ) ) {
return $vp_data;
}
$args = array(
'post_date' => $vp_data->upload_date,
'post_title' => wp_kses( $vp_data->title, array() ),
'post_content' => wp_kses( $vp_data->description, array() ),
'post_mime_type' => 'video/videopress',
'guid' => sprintf( 'https://videopress.com/v/%s', $guid ),
);
$attachment_id = wp_insert_attachment( $args, null, $parent_id );
if ( ! is_wp_error( $attachment_id ) ) {
update_post_meta( $attachment_id, 'videopress_guid', $guid );
wp_update_attachment_metadata(
$attachment_id,
array(
'width' => $vp_data->width,
'height' => $vp_data->height,
)
);
$thumbnail_id = videopress_download_poster_image( $vp_data->poster, $attachment_id );
update_post_meta( $attachment_id, '_thumbnail_id', $thumbnail_id );
}
return $attachment_id;
}
/**
* Helper that will look for VideoPress media items that are more than 30 minutes old,
* that have not had anything attached to them by a wpcom upload and deletes the ghost
* attachment.
*
* These happen primarily because of failed upload attempts.
*
* @return int The number of items that were cleaned up.
*/
function videopress_cleanup_media_library() {
// phpcs:disable Squiz.PHP.NonExecutableCode.Unreachable -- Function is disabled currently.
// Disable this job for now.
return 0;
$query_args = array(
'post_type' => 'attachment',
'post_status' => 'inherit',
'post_mime_type' => 'video/videopress',
'meta_query' => array(
array(
'key' => 'videopress_status',
'value' => 'new',
),
),
);
$query = new WP_Query( $query_args );
$cleaned = 0;
$now = current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- Probably should refactor, but this function is disabled.
if ( $query->have_posts() ) {
foreach ( $query->posts as $post ) {
$post_time = strtotime( $post->post_date_gmt );
// If the post is older than 30 minutes, it is safe to delete it.
if ( $now - $post_time > MINUTE_IN_SECONDS * 30 ) {
// Force delete the attachment, because we don't want it appearing in the trash.
wp_delete_attachment( $post->ID, true );
++$cleaned;
}
}
}
return $cleaned;
// phpcs:enable Squiz.PHP.NonExecutableCode.Unreachable
}
/**
* Return an absolute URI for a given filename and guid on the CDN.
* No check is performed to ensure the guid exists or the file is present. Simple centralized string builder.
*
* @param string $guid VideoPress identifier.
* @param string $filename name of file associated with the guid (video file name or thumbnail file name).
*
* @return string Absolute URL of VideoPress file for the given guid.
*/
function videopress_cdn_file_url( $guid, $filename ) {
return "https://videos.files.wordpress.com/{$guid}/{$filename}";
}
/**
* Get an array of the transcoding status for the given video post.
*
* @since 4.4
* @param int $post_id Post ID.
* @return array|bool Returns an array of statuses if this is a VideoPress post, otherwise it returns false.
*/
function videopress_get_transcoding_status( $post_id ) {
$meta = wp_get_attachment_metadata( $post_id );
// If this has not been processed by videopress, we can skip the rest.
if ( ! $meta || ! isset( $meta['file_statuses'] ) ) {
return false;
}
$info = (object) $meta['file_statuses'];
$status = array(
'std_mp4' => $info->mp4 ?? null,
'std_ogg' => $info->ogg ?? null,
'dvd_mp4' => $info->dvd ?? null,
'hd_mp4' => $info->hd ?? null,
);
return $status;
}
/**
* Get the direct url to the video.
*
* @since 4.4
* @param string $guid VideoPress GUID.
* @return string
*/
function videopress_build_url( $guid ) {
// No guid, no videopress url.
if ( ! $guid ) {
return '';
}
return 'https://videopress.com/v/' . $guid;
}
/**
* Create an empty videopress media item that will be filled out later by an xmlrpc
* callback from the VideoPress servers.
*
* @since 4.4
* @param string $title The post_title.
* @param string $guid The VideoPress guid.
* @return int|WP_Error
*/
function videopress_create_new_media_item( $title, $guid = null ) {
$post = array(
'post_type' => 'attachment',
'post_mime_type' => 'video/videopress',
'post_title' => $title,
'post_content' => '',
'guid' => videopress_build_url( $guid ),
);
$media_id = wp_insert_post( $post );
add_post_meta( $media_id, 'videopress_status', 'initiated' );
add_post_meta( $media_id, 'videopress_guid', $guid );
return $media_id;
}
/**
* Merge VideoPress file status metadata.
*
* @param array $current_status The current status of the video.
* @param array $new_meta The new meta data to merge with the current status.
* @return array
*/
function videopress_merge_file_status( $current_status, $new_meta ) {
$new_statuses = array();
if ( isset( $new_meta['videopress']['files_status']['hd'] ) ) {
$new_statuses['hd'] = $new_meta['videopress']['files_status']['hd'];
}
if ( isset( $new_meta['videopress']['files_status']['dvd'] ) ) {
$new_statuses['dvd'] = $new_meta['videopress']['files_status']['dvd'];
}
if ( isset( $new_meta['videopress']['files_status']['std']['mp4'] ) ) {
$new_statuses['mp4'] = $new_meta['videopress']['files_status']['std']['mp4'];
}
if ( isset( $new_meta['videopress']['files_status']['std']['ogg'] ) ) {
$new_statuses['ogg'] = $new_meta['videopress']['files_status']['std']['ogg'];
}
foreach ( $new_statuses as $format => $status ) {
if ( ! isset( $current_status[ $format ] ) ) {
$current_status[ $format ] = $status;
continue;
}
if ( $current_status[ $format ] !== 'DONE' ) {
$current_status[ $format ] = $status;
}
}
return $current_status;
}
/**
* Check to see if a video has completed processing.
*
* @since 4.4
* @param int $post_id Post ID.
* @return bool
*/
function videopress_is_finished_processing( $post_id ) {
$post = get_post( $post_id );
if ( is_wp_error( $post ) ) {
return false;
}
$meta = wp_get_attachment_metadata( $post->ID );
if ( ! isset( $meta['videopress']['finished'] ) ) {
return false;
}
return $meta['videopress']['finished'];
}
/**
* Update the meta information status for the given video post.
*
* @since 4.4
* @param int $post_id Post ID.
* @return bool
*/
function videopress_update_meta_data( $post_id ) {
$meta = wp_get_attachment_metadata( $post_id );
// If this has not been processed by VideoPress, we can skip the rest.
if ( ! $meta || ! isset( $meta['videopress'] ) ) {
return false;
}
$info = (object) $meta['videopress'];
// Without a guid there is no video to query for.
if ( ! isset( $info->guid ) ) {
return false;
}
$result = Client::wpcom_json_api_request_as_blog( 'videos/' . $info->guid );
if ( is_wp_error( $result ) ) {
return false;
}
$response = json_decode( $result['body'], true );
// Update the attachment metadata.
$meta['videopress'] = $response;
wp_update_attachment_metadata( $post_id, $meta );
return true;
}
/**
* Check to see if this is a VideoPress post that hasn't had a guid set yet.
*
* @param int $post_id Post ID.
* @return bool
*/
function videopress_is_attachment_without_guid( $post_id ) {
$post = get_post( $post_id );
if ( is_wp_error( $post ) ) {
return false;
}
if ( $post->post_mime_type !== 'video/videopress' ) {
return false;
}
$videopress_guid = get_post_meta( $post_id, 'videopress_guid', true );
if ( $videopress_guid ) {
return false;
}
return true;
}
/**
* Check to see if this is a VideoPress attachment.
*
* @param int $post_id Post ID.
* @return bool
*/
function is_videopress_attachment( $post_id ) {
$post = get_post( $post_id );
if ( ! $post instanceof WP_Post ) {
return false;
}
if ( $post->post_mime_type !== 'video/videopress' ) {
return false;
}
return true;
}
/**
* Get the video update path
*
* @since 4.4
* @param string $guid VideoPress GUID.
* @return string
*/
function videopress_make_video_get_path( $guid ) {
return sprintf(
'%s/rest/v%s/videos/%s',
JETPACK__WPCOM_JSON_API_BASE,
Client::WPCOM_JSON_API_VERSION,
$guid
);
}
/**
* Get the upload api path.
*
* @since 4.4
* @param int $blog_id The id of the blog we're uploading to.
* @return string
*/
function videopress_make_media_upload_path( $blog_id ) {
return sprintf(
'https://public-api.wordpress.com/rest/v1.1/sites/%s/media/new?locale=%s',
$blog_id,
get_locale()
);
}
/**
* Get the resumable upload api path.
*
* @since 4.4
* @param int $blog_id The id of the blog we're uploading to.
* @return string
*/
function videopress_make_resumable_upload_path( $blog_id ) {
return sprintf(
'https://public-api.wordpress.com/rest/v1.1/video-uploads/%s/',
$blog_id
);
}
/**
* This is a mock of the internal VideoPress method, which is meant to duplicate the functionality
* of the WPCOM API, so that the Jetpack REST API returns the same data with no modifications.
*
* @param int $blog_id Blog ID.
* @param int $post_id Post ID.
* @return stdClass
*/
function video_get_info_by_blogpostid( $blog_id, $post_id ) {
$post = get_post( $post_id );
$video_info = new stdClass();
$video_info->post_id = 0;
$video_info->description = '';
$video_info->title = '';
$video_info->caption = '';
$video_info->blog_id = $blog_id;
$video_info->guid = null;
$video_info->finish_date_gmt = '0000-00-00 00:00:00';
$video_info->rating = null;
$video_info->privacy_setting = VIDEOPRESS_PRIVACY::SITE_DEFAULT;
if ( ! $post ) {
return $video_info;
}
$video_info->post_id = $post_id;
$video_info->description = $post->post_content;
$video_info->title = $post->post_title;
$video_info->caption = $post->post_excerpt;
if ( 'video/videopress' !== $post->post_mime_type ) {
return $video_info;
}
// Since this is a VideoPress post, lt's fill out the rest of the object.
$video_info->guid = get_post_meta( $post_id, 'videopress_guid', true );
$meta = wp_get_attachment_metadata( $post_id );
if ( $meta && isset( $meta['videopress'] ) ) {
$videopress_meta = $meta['videopress'];
$video_info->rating = $videopress_meta['rating'] ?? null;
$video_info->allow_download = $videopress_meta['allow_download'] ?? 0;
$video_info->display_embed = $videopress_meta['display_embed'] ?? 0;
$video_info->privacy_setting = ! isset( $videopress_meta['privacy_setting'] ) ? VIDEOPRESS_PRIVACY::SITE_DEFAULT : $videopress_meta['privacy_setting'];
if ( ! empty( $videopress_meta['finished'] ) ) {
$video_info->finish_date_gmt = gmdate( 'Y-m-d H:i:s', (int) $videopress_meta['finished'] );
}
}
/** Make sure we are keeping some meta keys updated for filtering purposes */
if ( get_post_meta( $post_id, 'videopress_rating', true ) !== $video_info->rating ) {
update_post_meta( $post_id, 'videopress_rating', $video_info->rating );
}
if ( get_post_meta( $post_id, 'videopress_privacy_setting', true ) !== $video_info->privacy_setting ) {
update_post_meta( $post_id, 'videopress_privacy_setting', $video_info->privacy_setting );
}
return $video_info;
}
/**
* Check that a VideoPress video format has finished processing.
*
* This uses the info object, because that is what the WPCOM endpoint
* uses, however we don't have a complete info object in the same way
* WPCOM does, so we pull the meta information out of the post
* options instead.
*
* Note: This mimics the WPCOM function of the same name and helps the media
* API endpoint add all needed VideoPress data.
*
* @param stdClass $info Info object.
* @param string $format Video format.
* @return bool
*/
function video_format_done( $info, $format ) {
// Avoids notice when a non-videopress item is found.
if ( ! is_object( $info ) ) {
return false;
}
$post_id = $info->post_id;
if ( get_post_mime_type( $post_id ) !== 'video/videopress' ) {
return false;
}
$post = get_post( $post_id );
if ( is_wp_error( $post ) ) {
return false;
}
$meta = wp_get_attachment_metadata( $post->ID );
$video_format = str_replace( array( 'fmt_', 'fmt1_' ), '', $format );
if ( 'ogg' === $video_format ) {
return isset( $meta['videopress']['files']['std']['ogg'] );
} else {
return isset( $meta['videopress']['files'][ $video_format ]['mp4'] );
}
}
/**
* Get the image URL for the given VideoPress GUID
*
* We look up by GUID, because that is what WPCOM does and this needs to be
* parameter compatible with that.
*
* Note: This mimics the WPCOM function of the same name and helps the media
* API endpoint add all needed VideoPress data.
*
* @param string $guid VideoPress GUID.
* @param string $format Video format.
* @return string|null The poster image URL, or null if it cannot be resolved.
*/
function video_image_url_by_guid( $guid, $format ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$post = videopress_get_post_by_guid( $guid );
if ( is_wp_error( $post ) || ! $post ) {
return null;
}
$meta = wp_get_attachment_metadata( $post->ID );
if ( ! is_array( $meta ) || ! isset( $meta['videopress']['poster'] ) ) {
return null;
}
$poster = apply_filters( 'jetpack_photon_url', $meta['videopress']['poster'] );
return $poster;
}
/**
* Using a GUID, find a post.
*
* @param string $guid The post guid.
* @return WP_Post|false The post for that guid, or false if none is found.
*/
function videopress_get_post_by_guid( $guid ) {
$cache_key = 'get_post_by_guid_' . $guid;
$cache_group = 'videopress';
$cached_post = wp_cache_get( $cache_key, $cache_group );
if ( is_object( $cached_post ) && 'WP_Post' === get_class( $cached_post ) ) {
return $cached_post;
}
$post_id = videopress_get_post_id_by_guid( $guid );
if ( is_int( $post_id ) ) {
$post = get_post( $post_id );
wp_cache_set( $cache_key, $post, $cache_group, HOUR_IN_SECONDS );
return $post;
}
return false;
}
/**
* Using a GUID, find the associated post ID.
*
* @since 8.4.0
* @param string $guid The guid to look for the post ID of.
* @return int|false The post ID for that guid, or false if none is found.
*/
function videopress_get_post_id_by_guid( $guid ) {
$cache_key = 'videopress_get_post_id_by_guid_' . $guid;
$cached_id = get_transient( $cache_key );
if ( is_int( $cached_id ) ) {
return $cached_id;
}
$args = array(
'post_type' => 'attachment',
'post_mime_type' => 'video/videopress',
'post_status' => 'inherit',
'no_found_rows' => true,
'fields' => 'ids',
'meta_query' => array(
array(
'key' => 'videopress_guid',
'value' => $guid,
'compare' => '=',
),
),
);
$query = new WP_Query( $args );
if ( $query->have_posts() ) {
$post_id = $query->next_post();
set_transient( $cache_key, $post_id, HOUR_IN_SECONDS );
return $post_id;
}
return false;
}
/**
* From the given VideoPress post_id, return back the appropriate attachment URL.
*
* When the MP4 hasn't been processed yet or this is not a VideoPress video, this will return null.
*
* @param int $post_id Post ID of the attachment.
* @return string|null
*/
function videopress_get_attachment_url( $post_id ) {
// We only handle VideoPress attachments.
if ( get_post_mime_type( $post_id ) !== 'video/videopress' ) {
return null;
}
$meta = wp_get_attachment_metadata( $post_id );
// As of Jetpack 10.3 transcoded video files are reserved for the VideoPress player.
// All other video file requests will receive the originally uploaded file, stored on the wpcom cdn.
if ( ! isset( $meta['videopress']['original'] ) ) {
// Use the original file as the url if it isn't transcoded yet.
if ( isset( $meta['original'] ) ) {
$return = $meta['original'];
} else {
// Otherwise, there isn't much we can do.
return null;
}
} else {
$return = $meta['videopress']['original'];
}
// If the URL is a string, return it. Otherwise, we shouldn't to avoid errors downstream, so null.
return ( is_string( $return ) ) ? $return : null;
}
/**
* Converts VideoPress flash embeds into oEmbed-able URLs.
*
* Older VideoPress embed depended on Flash, which no longer work,
* so let us convert them to an URL that WordPress can oEmbed.
*
* Note that this file is always loaded via modules/module-extras.php and is not dependent on module status.
*
* @param string $content the content.
* @return string filtered content
*/
function jetpack_videopress_flash_embed_filter( $content ) {
// This receives data from the `the_content` filter, which unfortunately sometimes has bad content passed along as a param.
if ( ! is_string( $content ) ) {
return $content;
}
$regex = '%<embed[^>]*+>(?:\s*</embed>)?%i';
$content = preg_replace_callback(
$regex,
function ( $matches ) {
$embed_code = $matches[0];
$url_matches = array();
// get video ID from flash URL.
$url_matched = preg_match( '/src="http:\/\/v.wordpress.com\/([^"]+)"/', $embed_code, $url_matches );
if ( $url_matched ) {
$video_id = $url_matches[1];
return "https://videopress.com/v/$video_id";
}
},
$content
);
return $content;
}
/**
* Checks if the provided rating string is a valid VideoPress video rating value.
*
* @param mixed $rating The video rating to validate.
* @return bool
*/
function videopress_is_valid_video_rating( $rating ) {
return in_array( $rating, array( 'G', 'PG-13', 'R-17' ), true );
}
add_filter( 'the_content', 'jetpack_videopress_flash_embed_filter', 7 ); // Needs to be priority 7 to allow Core to oEmbed.
if ( ! function_exists( 'wp_startswith' ) ) :
/**
* Check whether a string starts with a specific substring.
*
* @param string $haystack String we are filtering.
* @param string $needle The substring we are looking for.
* @return bool
*/
function wp_startswith( $haystack, $needle ) {
if ( ! $haystack || ! $needle || ! is_scalar( $haystack ) || ! is_scalar( $needle ) ) {
return false;
}
$haystack = (string) $haystack;
$needle = (string) $needle;
return str_starts_with( $haystack, $needle );
}
endif;
@@ -0,0 +1,92 @@
<?php
/**
* Divi 5 integration bootstrap for VideoPress.
*
* @package automattic/jetpack-videopress
*/
declare( strict_types = 1 );
namespace Automattic\Jetpack\VideoPress\Divi5;
use Automattic\Jetpack\VideoPress\Package_Version;
use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;
use ET\Builder\VisualBuilder\Assets\PackageBuildManager;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Wires the VideoPress module into the Divi 5 framework.
*
* Both hooks below are only fired by Divi 5, so this integration stays inert on
* Divi 4 (where the legacy module continues to handle rendering).
*/
class Divi_5 {
/**
* Registers the Divi 5 hooks.
*
* @return void
*/
public static function init() {
add_action( 'divi_module_library_modules_dependency_tree', array( __CLASS__, 'register_module' ) );
add_action( 'divi_visual_builder_assets_before_enqueue_scripts', array( __CLASS__, 'enqueue_visual_builder_assets' ) );
/*
* Divi builds its module library during theme bootstrap, which fires the
* dependency-tree action above (once) before this integration loads on
* `init` — so the callback misses it on requests like the Divi 5 Migrator's
* admin-ajax calls. Register directly here too; VideoPress_Module::load()
* is idempotent, so the two paths never double-register.
*/
if ( class_exists( ModuleRegistration::class, false ) ) {
( new VideoPress_Module() )->load();
}
}
/**
* Adds the VideoPress module to the Divi 5 module dependency tree.
*
* @param object $dependency_tree The Divi 5 module dependency tree.
*
* @return void
*/
public static function register_module( $dependency_tree ) {
$dependency_tree->add_dependency( new VideoPress_Module() );
}
/**
* Registers the Visual Builder bundle that powers the module's editing UI.
*
* @return void
*/
public static function enqueue_visual_builder_assets() {
$asset_file = dirname( __DIR__, 2 ) . '/build/divi-5/index.asset.php';
$asset = file_exists( $asset_file ) ? require $asset_file : array();
PackageBuildManager::register_package_build(
array(
'name' => 'jetpack-videopress-divi5-visual-builder',
// The build content hash, so each build busts the browser cache.
'version' => $asset['version'] ?? Package_Version::PACKAGE_VERSION,
'script' => array(
'src' => plugins_url( '../../build/divi-5/index.js', __FILE__ ),
/*
* The handles the build emits (react, react-jsx-runtime and the
* Divi-vendored @wordpress/* instances), plus the Divi builder
* handles that provide the window.divi.* globals.
*/
'deps' => array_merge(
array( 'divi-module-library', 'divi-rest' ),
$asset['dependencies'] ?? array()
),
'enqueue_top_window' => false,
'enqueue_app_window' => true,
),
)
);
}
}
@@ -0,0 +1,75 @@
<?php
/**
* Divi 5 VideoPress module.
*
* @package automattic/jetpack-videopress
*/
declare( strict_types = 1 );
namespace Automattic\Jetpack\VideoPress\Divi5;
use Automattic\Jetpack\VideoPress\Divi5\Traits\Module_Classnames_Trait;
use Automattic\Jetpack\VideoPress\Divi5\Traits\Module_Script_Data_Trait;
use Automattic\Jetpack\VideoPress\Divi5\Traits\Module_Styles_Trait;
use Automattic\Jetpack\VideoPress\Divi5\Traits\Render_Callback_Trait;
use ET\Builder\Framework\DependencyManagement\Interfaces\DependencyInterface;
use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Registers and renders the VideoPress module within the Divi 5 framework.
*
* The `ET\Builder\*` symbols this class relies on only exist while Divi 5 is
* active, which is why the class is loaded lazily from hooks that Divi 5 fires.
*/
class VideoPress_Module implements DependencyInterface {
use Render_Callback_Trait;
use Module_Classnames_Trait;
use Module_Styles_Trait;
use Module_Script_Data_Trait;
/**
* Matches a VideoPress URL or GUID and captures the GUID. Kept in sync with
* the regex used by the Visual Builder renderer.
*
* @var string
*/
const VIDEOPRESS_REGEX = '/^(?:(?:http(?:s)?:\/\/)?(?:www\.)?video(?:\.word)?press\.com\/(?:v|embed)\/)?([a-zA-Z\d]+)(?:.*)?/i';
/**
* The module's block name. Must match the `name` field in `module.json`.
*
* @var string
*/
const MODULE_NAME = 'jetpack/videopress';
/**
* Registers the module with the Divi 5 module library.
*
* @return void
*/
public function load() {
/*
* Divi walks its dependency tree more than once per request, and
* Divi_5::init() also calls this directly, so guard on the registry to
* register exactly once. Registering before `init` matches how Divi
* registers its own modules during theme bootstrap.
*/
if ( \WP_Block_Type_Registry::get_instance()->is_registered( self::MODULE_NAME ) ) {
return;
}
// Read from build/ (shipped) rather than src/client/ (production-excluded).
ModuleRegistration::register_module(
dirname( __DIR__, 2 ) . '/build/divi-5/modules/videopress',
array(
'render_callback' => array( self::class, 'render_callback' ),
)
);
}
}
@@ -0,0 +1,40 @@
<?php
/**
* Custom CSS fields for the Divi 5 VideoPress module.
*
* @package automattic/jetpack-videopress
*/
declare( strict_types = 1 );
namespace Automattic\Jetpack\VideoPress\Divi5\Traits;
use Automattic\Jetpack\VideoPress\Divi5\VideoPress_Module;
use WP_Block_Type;
use WP_Block_Type_Registry;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Exposes the module's custom CSS fields, as declared in `module.json`.
*/
trait Custom_Css_Trait {
/**
* Returns the registered custom CSS fields for the module.
*
* @return array The custom CSS field definitions.
*/
public static function custom_css() {
$block_type = WP_Block_Type_Registry::get_instance()->get_registered( VideoPress_Module::MODULE_NAME );
if ( ! $block_type instanceof WP_Block_Type ) {
return array();
}
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Property defined by the Divi 5 framework.
return $block_type->customCssFields ?? array();
}
}
@@ -0,0 +1,42 @@
<?php
/**
* Class name output for the Divi 5 VideoPress module.
*
* @package automattic/jetpack-videopress
*/
declare( strict_types = 1 );
namespace Automattic\Jetpack\VideoPress\Divi5\Traits;
use ET\Builder\Packages\Module\Options\Element\ElementClassnames;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Emits the module's class names, mirroring the Visual Builder classnames function.
*/
trait Module_Classnames_Trait {
/**
* Adds the module's decoration class names.
*
* @param array $args The classnames callback arguments.
*
* @return void
*/
public static function module_classnames( $args ) {
$classnames_instance = $args['classnamesInstance'];
$attrs = $args['attrs'];
$classnames_instance->add(
ElementClassnames::classnames(
array(
'attrs' => $attrs['module']['decoration'] ?? array(),
)
)
);
}
}
@@ -0,0 +1,38 @@
<?php
/**
* Front-end script data for the Divi 5 VideoPress module.
*
* @package automattic/jetpack-videopress
*/
declare( strict_types = 1 );
namespace Automattic\Jetpack\VideoPress\Divi5\Traits;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Registers the module's front-end script data, mirroring the Visual Builder
* script data component.
*/
trait Module_Script_Data_Trait {
/**
* Registers the module's script data.
*
* @param array $args The script data callback arguments.
*
* @return void
*/
public static function module_script_data( $args ) {
$elements = $args['elements'];
$elements->script_data(
array(
'attrName' => 'module',
)
);
}
}
@@ -0,0 +1,66 @@
<?php
/**
* Style output for the Divi 5 VideoPress module.
*
* @package automattic/jetpack-videopress
*/
declare( strict_types = 1 );
namespace Automattic\Jetpack\VideoPress\Divi5\Traits;
use ET\Builder\FrontEnd\Module\Style;
use ET\Builder\Packages\Module\Options\Css\CssStyle;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Emits the module's styles, mirroring the Visual Builder style component.
*/
trait Module_Styles_Trait {
use Custom_Css_Trait;
/**
* Adds the module's styles to the style registry.
*
* @param array $args The style callback arguments.
*
* @return void
*/
public static function module_styles( $args ) {
$attrs = $args['attrs'] ?? array();
$elements = $args['elements'];
$settings = $args['settings'] ?? array();
Style::add(
array(
'id' => $args['id'] ?? '',
'name' => $args['name'] ?? '',
'orderIndex' => $args['orderIndex'] ?? 0,
'storeInstance' => $args['storeInstance'] ?? null,
'styles' => array(
$elements->style(
array(
'attrName' => 'module',
'styleProps' => array(
'disabledOn' => array(
'disabledModuleVisibility' => $settings['disabledModuleVisibility'] ?? null,
),
),
)
),
CssStyle::style(
array(
'selector' => $args['orderClass'],
'attr' => $attrs['css'] ?? array(),
'cssFields' => self::custom_css(),
)
),
),
)
);
}
}
@@ -0,0 +1,180 @@
<?php
/**
* Render callback for the Divi 5 VideoPress module.
*
* @package automattic/jetpack-videopress
*/
declare( strict_types = 1 );
namespace Automattic\Jetpack\VideoPress\Divi5\Traits;
use Automattic\Jetpack\VideoPress\Divi5\VideoPress_Module;
use Automattic\Jetpack\VideoPress\Jwt_Token_Bridge;
use Automattic\Jetpack\VideoPress\Package_Version;
use ET\Builder\FrontEnd\BlockParser\BlockParserStore;
use ET\Builder\Packages\Module\Module;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Builds the front-end markup for the VideoPress module.
*/
trait Render_Callback_Trait {
/**
* Renders the module on the front end.
*
* @param array $attrs The module attributes.
* @param string $content The module content.
* @param object $block The parsed block object.
* @param object $elements The module element helpers.
*
* @return string The rendered HTML, or an empty string when no valid GUID is set.
*/
public static function render_callback( $attrs, $content, $block, $elements ) {
$guid_value = $attrs['guid']['innerContent']['desktop']['value'] ?? '';
$matches = array();
if ( ! preg_match( VideoPress_Module::VIDEOPRESS_REGEX, (string) $guid_value, $matches ) || ! isset( $matches[1] ) ) {
return '';
}
Jwt_Token_Bridge::enqueue_jwt_token_bridge();
$video_player = self::render_video_player( $matches[1], $attrs );
$parent = BlockParserStore::get_parent( $block->parsed_block['id'], $block->parsed_block['storeInstance'] );
return Module::render(
array(
'orderIndex' => $block->parsed_block['orderIndex'],
'storeInstance' => $block->parsed_block['storeInstance'],
'attrs' => $attrs,
'elements' => $elements,
'id' => $block->parsed_block['id'],
'moduleClassName' => '',
'name' => $block->block_type->name,
'classnamesFunction' => array( VideoPress_Module::class, 'module_classnames' ),
'moduleCategory' => $block->block_type->category,
'stylesComponent' => array( VideoPress_Module::class, 'module_styles' ),
'scriptDataComponent' => array( VideoPress_Module::class, 'module_script_data' ),
'parentAttrs' => $parent->attrs ?? array(),
'parentId' => $parent->id ?? '',
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Property defined by the Divi 5 framework.
'parentName' => $parent->blockName ?? '',
'children' => $elements->style_components(
array(
'attrName' => 'module',
)
) . $video_player,
)
);
}
/**
* Builds the VideoPress iframe wrapper markup for a GUID.
*
* @param string $guid The VideoPress GUID.
* @param array $attrs The module attributes, used to derive player options.
*
* @return string The iframe wrapper markup.
*/
private static function render_video_player( $guid, $attrs ) {
/*
* Enqueue the shared VideoPress iframe API bootstrap rather than printing a
* <script> per render. This reuses the `videopress-iframe` handle the
* VideoPress block registers, so the script loads once per page no matter
* how many videos (or blocks) are present.
*/
wp_enqueue_script(
'videopress-iframe',
'https://videopress.com/videopress-iframe.js',
array(),
Package_Version::PACKAGE_VERSION,
true
);
$iframe_title = sprintf(
/* translators: %s: Video GUID. */
esc_html__( 'Video player for %s', 'jetpack-videopress-pkg' ),
esc_html( $guid )
);
$iframe_src = self::build_embed_url( $guid, $attrs );
return '<div class="vidi-videopress-wrapper" style="position:relative;width:100%;height:0;padding-bottom:56.25%;">' .
'<iframe title="' . esc_attr( $iframe_title ) . '" src="' . esc_url( $iframe_src ) . '" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" frameborder="0" allowfullscreen></iframe>' .
'</div>';
}
/**
* Builds the VideoPress embed URL, applying only the player options that differ
* from the VideoPress player defaults (for a clean URL, mirroring the block).
* Kept in sync with `getEmbedUrl()` in the Visual Builder's `utils.js`.
*
* @param string $guid The VideoPress GUID.
* @param array $attrs The module attributes.
*
* @return string The embed URL.
*/
private static function build_embed_url( $guid, $attrs ) {
$options = self::get_player_options( $attrs );
$params = array();
if ( $options['autoplay'] ) {
$params['autoPlay'] = '1';
}
if ( $options['loop'] ) {
$params['loop'] = '1';
}
if ( $options['muted'] ) {
$params['muted'] = '1';
$params['persistVolume'] = '0';
}
if ( ! $options['controls'] ) {
$params['controls'] = '0';
}
if ( $options['playsinline'] ) {
$params['playsinline'] = '1';
}
// Always identify the embedder so VideoPress can attribute Divi traffic.
$params['embedder'] = 'divi-builder';
return add_query_arg( $params, 'https://videopress.com/embed/' . rawurlencode( $guid ) );
}
/**
* Resolves the module's player options from its attributes, falling back to the
* VideoPress player defaults when a toggle is unset.
*
* @param array $attrs The module attributes.
*
* @return array<string, bool> The resolved player options.
*/
private static function get_player_options( $attrs ) {
return array(
'autoplay' => self::is_toggle_on( $attrs, 'autoplay', false ),
'loop' => self::is_toggle_on( $attrs, 'loop', false ),
'muted' => self::is_toggle_on( $attrs, 'muted', false ),
'controls' => self::is_toggle_on( $attrs, 'controls', true ),
'playsinline' => self::is_toggle_on( $attrs, 'playsinline', false ),
);
}
/**
* Reads a Divi 5 toggle attribute, which stores `'on'`/`'off'` strings.
*
* @param array $attrs The module attributes.
* @param string $name The attribute name.
* @param bool $default_value The value to use when the toggle is unset.
*
* @return bool Whether the toggle is on.
*/
private static function is_toggle_on( $attrs, $name, $default_value ) {
$value = $attrs[ $name ]['innerContent']['desktop']['value'] ?? ( $default_value ? 'on' : 'off' );
return 'on' === $value;
}
}
@@ -0,0 +1,153 @@
<?php
/**
* Divi integration for Videopress.
*
* @package automattic/jetpack-videopress
**/
use Automattic\Jetpack\Assets;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* Divi extension.
**/
class VideoPress_Divi_Extension extends DiviExtension {
/**
* The extension's js file namespace.
*
* @var string
*/
const JETPACK_VIDEOPRESS_DIVI_PKG_NAMESPACE = 'jetpack-videopress-divi-pkg';
/**
* The gettext domain for the extension's translations.
*
* @since 0.14.0
*
* @var string
*/
public $gettext_domain = 'jetpack-videopress-pkg';
/**
* The extension's WP Plugin name.
*
* @since 0.14.0
*
* @var string
*/
public $name = 'videopress-divi';
/**
* The extension's version
*
* @since 0.14.0
*
* @var string
*/
public $version = '1.0.0';
/**
* The VideoPress_Divi_Module.
*
* @since 0.14.0
*
* @var VideoPress_Divi_Module
*/
private $videopress_divi_module;
/**
* Plugin directory.
*
* @var string
*/
public $plugin_dir;
/**
* Plugin directory URL.
*
* @var string
*/
public $plugin_dir_url;
/**
* The constructor.
*
* @param string $name The name.
* @param array $args The args.
*/
public function __construct( $name = 'videopress-divi', $args = array() ) {
$this->plugin_dir = plugin_dir_path( __FILE__ );
$this->plugin_dir_url = plugin_dir_url( $this->plugin_dir );
parent::__construct( $name, $args );
}
/**
* Executes when the ET builder module is loaded.
*
* @override
*/
public function hook_et_builder_modules_loaded() {
$this->hook_et_builder_ready();
}
/**
* Loads custom modules when the builder is ready.
*
* @override
*/
public function hook_et_builder_ready() {
require_once plugin_dir_path( __FILE__ ) . 'class-videopress-divi-module.php';
$this->videopress_divi_module = new VideoPress_Divi_Module();
}
/**
* Performs initialization tasks.
*
* @Override
*/
protected function _initialize() { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore
DiviExtensions::add( $this );
$this->_set_debug_mode();
$this->_set_bundle_dependencies();
// Register callbacks.
register_activation_hook( trailingslashit( $this->plugin_dir ) . $this->name . '.php', array( $this, 'wp_hook_activate' ) );
add_action( 'et_builder_ready', array( $this, 'hook_et_builder_ready' ), 9 );
add_action( 'wp_enqueue_scripts', array( $this, 'wp_hook_enqueue_scripts' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'register_js_assets' ) );
}
/**
* Register the extension's js assets.
*/
public function register_js_assets() {
Assets::register_script(
self::JETPACK_VIDEOPRESS_DIVI_PKG_NAMESPACE,
'../../build/divi-editor/index.js',
__FILE__,
array(
'in_footer' => true,
'css_path' => '../../build/divi-editor/index.css',
'textdomain' => 'jetpack-videopress-pkg',
'dependencies' => array( 'jquery' ),
)
);
Assets::enqueue_script( self::JETPACK_VIDEOPRESS_DIVI_PKG_NAMESPACE );
}
/**
* Enqueue frontend stuff.
*
* @override
*/
public function wp_hook_enqueue_scripts() {
}
}
@@ -0,0 +1,142 @@
<?php
/**
* VideoPress Divi Editor module.
*
* @package VideoPress
*/
use Automattic\Jetpack\VideoPress\Jwt_Token_Bridge;
if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}
/**
* VideoPress Divi module
**/
class VideoPress_Divi_Module extends ET_Builder_Module {
/**
* Module slug
*
* @var string
*/
public $slug = 'divi_videopress';
/**
* For matching VideoPress urls or guids.
*
* @var string
*/
const VIDEOPRESS_REGEX = '/^(?:(?:http(?:s)?:\/\/)?(?:www\.)?video(?:\.word)?press\.com\/(?:v|embed)\/)?([a-zA-Z\d]+)(?:.*)?/i';
/**
* Vd support.
*
* @var string
*/
public $vb_support = 'on';
/**
* Credits.
*
* @var array
*/
protected $module_credits = array(
'module_uri' => 'https://automattic.com',
'author' => 'Automattic Inc',
'author_uri' => 'https://automattic.com',
);
/**
* Name.
*
* @var string
*/
public $name;
/**
* Icon.
*
* @var string
*/
public $icon;
/**
* Properties.
*
* @var array
*/
public $props;
/**
* Initialize the thing.
*/
public function init() {
$this->name = esc_html__( 'VideoPress', 'jetpack-videopress-pkg' );
$this->icon = 'q';
}
/**
* Get the fields of the block.
*
* @return array
*/
public function get_fields() {
return array(
'guid' => array(
'label' => esc_html__( 'URL or Video ID', 'jetpack-videopress-pkg' ),
'type' => 'text',
'option_category' => 'basic_option',
'description' => esc_html__( 'Paste a VideoPress URL or Video ID', 'jetpack-videopress-pkg' ),
'toggle_slug' => 'main_content',
),
);
}
/**
* Render.
*
* Note: while phpcs complains about unused vars in the method signature, they SHOULD be there. We override an existing method.
*
* @param array $attrs The attributes.
* @param string|null $content The content.
* @param string|null $render_slug The render slug.
*
* @return string
*/
public function render( $attrs, $content = null, $render_slug = null ) { // phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$matches = array();
if ( ! preg_match( self::VIDEOPRESS_REGEX, $this->props['guid'], $matches ) ) {
return '';
}
if ( ! isset( $matches[1] ) ) {
return '';
}
Jwt_Token_Bridge::enqueue_jwt_token_bridge();
$guid = $matches[1];
$iframe_title = sprintf(
/* translators: %s: Video title. */
esc_html__( 'Video player for %s', 'jetpack-videopress-pkg' ),
esc_html( $guid )
);
$iframe_src = sprintf(
'https://videopress.com/embed/%s?autoPlay=0&permalink=0&loop=0&embedder=divi-builder',
esc_attr( $guid )
);
$format_string = '<div class="vidi-videopress-wrapper"><iframe title="' .
esc_attr( $iframe_title ) .
'" src="' .
$iframe_src .
'" width="100%" height="100%" frameborder="0" allowfullscreen></iframe>' .
// phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript
'<script src="https://en.wordpress.com/wp-content/plugins/video/assets/js/next/videopress-iframe.js?m=1658739239"></script></div>';
return $format_string;
}
}