diff --git a/composer.json b/composer.json index a596e14..3dc2f4a 100644 --- a/composer.json +++ b/composer.json @@ -24,11 +24,9 @@ "require": { "php": ">=7.4", "ext-json": "*", - "firebase/php-jwt": "v6.9.0", - "ramsey/uuid": "4.2.3", + "ramsey/uuid": "^4.2", "salesforce/handlebars-php": "3.0.1", - "vlucas/phpdotenv": "v5.5.0", - "yahnis-elsts/plugin-update-checker": "v5.2" + "vlucas/phpdotenv": "v5.5.0" }, "config": { "platform": { @@ -47,12 +45,21 @@ "format": "phpcbf --standard=./.phpcs.xml.dist --report-summary --report-source", "lint": "phpcs --standard=./.phpcs.xml.dist", "lint-report": "phpcs --standard=./.phpcs.xml.dist --report=checkstyle", - "fix": "php-cs-fixer fix ." + "fix": "php-cs-fixer fix .", + "prefix-dependencies": [ + "@composer --working-dir=php-scoper install", + "php-scoper/vendor/bin/php-scoper add-prefix --config=php-scoper/config.php --output-dir=./vendor_prefixed --force", + "@composer dump-autoload -o -a" + ] }, "autoload": { "psr-4": { - "WCPOS\\WooCommercePOS\\": "includes/" - } + "WCPOS\\WooCommercePOS\\": "includes/" + }, + "classmap": [ + "vendor_prefixed/firebase/php-jwt/src", + "vendor_prefixed/yahnis-elsts/plugin-update-checker" + ] }, "autoload-dev": { "psr-4": { diff --git a/includes/API.php b/includes/API.php index 7b56479..d6833f8 100644 --- a/includes/API.php +++ b/includes/API.php @@ -26,12 +26,12 @@ class API { protected $controllers = array(); /** - * @var + * @var */ protected $wc_rest_api_handler; /** - * @var + * @var */ protected $auth_service; @@ -119,7 +119,7 @@ public function rest_pre_serve_request( $served, WP_HTTP_Response $result, WP_RE Logger::log( "Warning: 'rest_pre_serve_request' filter received a non-boolean value for 'served'. Defaulting to 'false'." ); $served = false; // Default value if not provided correctly } - + $server->send_header( 'Access-Control-Allow-Origin', '*' ); return $served; @@ -291,7 +291,7 @@ public function rest_dispatch_request( $dispatch_result, $request, $route, $hand } } } - + return $dispatch_result; } diff --git a/includes/API/Traits/Uuid_Handler.php b/includes/API/Traits/Uuid_Handler.php index 02ef2be..f79945c 100644 --- a/includes/API/Traits/Uuid_Handler.php +++ b/includes/API/Traits/Uuid_Handler.php @@ -19,14 +19,20 @@ trait Uuid_Handler { private function maybe_add_post_uuid( WC_Data $object ): void { $meta_data = $object->get_meta_data(); - $uuids = array_filter( $meta_data, function( WC_Meta_Data $meta ) { - return '_woocommerce_pos_uuid' === $meta->key; - }); - - $uuid_values = array_map( function( WC_Meta_Data $meta ) { - return $meta->value; - }, $uuids); - + $uuids = array_filter( + $meta_data, + function ( WC_Meta_Data $meta ) { + return '_woocommerce_pos_uuid' === $meta->key; + } + ); + + $uuid_values = array_map( + function ( WC_Meta_Data $meta ) { + return $meta->value; + }, + $uuids + ); + // If there is no uuid, add one, i.e., new product if ( empty( $uuid_values ) ) { $object->update_meta_data( '_woocommerce_pos_uuid', $this->create_uuid() ); @@ -40,7 +46,7 @@ private function maybe_add_post_uuid( WC_Data $object ): void { $object->update_meta_data( '_woocommerce_pos_uuid', $this->create_uuid() ); } } - + /** * @param WP_User $user diff --git a/includes/Init.php b/includes/Init.php index 3c3713b..8698e92 100644 --- a/includes/Init.php +++ b/includes/Init.php @@ -42,7 +42,7 @@ public function init(): void { new i18n(); new Gateways(); new Products(); - // new Customers(); + // new Customers(); new Orders(); // frontend only @@ -136,15 +136,15 @@ public function send_headers(): void { header( 'Access-Control-Expose-Headers: Link' ); } } - + /** * Loads POS integrations with third party plugins. */ private function integrations(): void { - // // WooCommerce Bookings - http://www.woothemes.com/products/woocommerce-bookings/ - // if ( class_exists( 'WC-Bookings' ) ) { - // new Integrations\Bookings(); - // } + // WooCommerce Bookings - http://www.woothemes.com/products/woocommerce-bookings/ + // if ( class_exists( 'WC-Bookings' ) ) { + // new Integrations\Bookings(); + // } // Yoast SEO - https://wordpress.org/plugins/wordpress-seo/ if ( class_exists( 'WPSEO_Options' ) ) { diff --git a/includes/Services/Auth.php b/includes/Services/Auth.php index d00caaa..25049ad 100644 --- a/includes/Services/Auth.php +++ b/includes/Services/Auth.php @@ -3,8 +3,8 @@ namespace WCPOS\WooCommercePOS\Services; use Exception; -use Firebase\JWT\JWT as FirebaseJWT; -use Firebase\JWT\Key as FirebaseKey; +use WCPOS\Vendor\Firebase\JWT\JWT; +use WCPOS\Vendor\Firebase\JWT\Key; use Ramsey\Uuid\Uuid; use WCPOS\WooCommercePOS\API\Stores; use WP_Error; @@ -12,20 +12,20 @@ use const DAY_IN_SECONDS; class Auth { - /** - * - */ - protected $stores_service; - - public function __construct() { - $this->stores_service = new Stores(); - } - - /** - * Generate a secret key if it doesn't exist, or return the existing one - * - * @return string - */ + /** + * + */ + protected $stores_service; + + public function __construct() { + $this->stores_service = new Stores(); + } + + /** + * Generate a secret key if it doesn't exist, or return the existing one + * + * @return string + */ public function get_secret_key(): string { $secret_key = get_option( 'woocommerce_pos_secret_key' ); if ( false === $secret_key || empty( $secret_key ) ) { @@ -35,24 +35,24 @@ public function get_secret_key(): string { return $secret_key; } - /** - * Validate the provided JWT token - * - * @param null $token - * - * @return object|WP_Error - */ - public function validate_token( $token = null ) { + /** + * Validate the provided JWT token + * + * @param string $token + * + * @return object|WP_Error + */ + public function validate_token( $token = '' ) { try { - $decoded_token = FirebaseJWT::decode( $token, new FirebaseKey( $this->get_secret_key(), 'HS256' ) ); + $decoded_token = JWT::decode( $token, new Key( $this->get_secret_key(), 'HS256' ) ); // The Token is decoded now validate the iss if ( get_bloginfo( 'url' ) != $decoded_token->iss ) { // The iss do not match, return error return new WP_Error( - 'woocommmerce_pos_auth_bad_iss', - 'The iss do not match with this server', - array( 'status' => 403 ) + 'woocommmerce_pos_auth_bad_iss', + 'The iss do not match with this server', + array( 'status' => 403 ) ); } @@ -60,163 +60,163 @@ public function validate_token( $token = null ) { if ( ! isset( $decoded_token->data->user->id ) ) { // No user id in the token, abort!! return new WP_Error( - 'woocommmerce_pos_auth_bad_request', - 'User ID not found in the token', - array( + 'woocommmerce_pos_auth_bad_request', + 'User ID not found in the token', + array( 'status' => 403, - ) + ) ); } /** Everything looks good return the decoded token */ - return $decoded_token; + return $decoded_token; } catch ( Exception $e ) { - // Something is wrong trying to decode the token, send back the error - return new WP_Error( - 'woocommmerce_pos_auth_invalid_token', - $e->getMessage(), - array( - 'status' => 403, - ) - ); + // Something is wrong trying to decode the token, send back the error + return new WP_Error( + 'woocommmerce_pos_auth_invalid_token', + $e->getMessage(), + array( + 'status' => 403, + ) + ); + } + } + + /** + * Generate a JWT token for the provided user + * + * @param WP_User $user + * + * @return string|WP_Error + */ + /** + * Generate a JWT token for the provided user + * + * @param WP_User $user + * + * @return string|WP_Error + */ + public function generate_token( WP_User $user ) { + // First thing, check the secret key if not exist return a error + if ( ! $this->get_secret_key() ) { + return new WP_Error( + 'woocommerce_pos_jwt_auth_bad_config', + __( 'JWT is not configured properly, please contact the admin', 'woocommerce-pos' ), + array( + 'status' => 403, + ) + ); } + + /** Valid credentials, the user exists create the according Token */ + $issued_at = time(); + + /** + * Filters the JWT issued at time. + * + * @param {string} $issued_at + * @returns {string} Issued at time + * @since 1.0.0 + * @hook woocommerce_pos_jwt_auth_not_before + */ + $not_before = apply_filters( 'woocommerce_pos_jwt_auth_not_before', $issued_at, $issued_at ); + + /** + * Filters the JWT expire time. + * + * @param {string} $issued_at + * @returns {string} Expire time + * @since 1.0.0 + * @hook woocommerce_pos_jwt_auth_expire + */ + $expire = apply_filters( 'woocommerce_pos_jwt_auth_expire', $issued_at + ( DAY_IN_SECONDS * 7 ), $issued_at ); + + $token = array( + 'iss' => get_bloginfo( 'url' ), + 'iat' => $issued_at, + 'nbf' => $not_before, + 'exp' => $expire, + 'data' => array( + 'user' => array( + 'id' => $user->data->ID, + ), + ), + ); + + /** + * Let the user modify the token data before the sign. + * + * @param {string} $token + * @param {WP_User} $user + * @returns {string} Token + * @since 1.0.0 + * @hook woocommerce_pos_jwt_auth_token_before_sign + */ + $token = JWT::encode( apply_filters( 'woocommerce_pos_jwt_auth_token_before_sign', $token, $user ), $this->get_secret_key(), 'HS256' ); + + return $token; + } + + + /** + * Get user's data + * + * @param WP_User $user + * + * @return array + */ + public function get_user_data( WP_User $user ): array { + $data = array( + 'uuid' => $this->get_user_uuid( $user ), + 'id' => $user->ID, + 'jwt' => $this->generate_token( $user ), + 'username' => $user->user_login, + 'email' => $user->user_email, + 'first_name' => $user->user_firstname, + 'last_name' => $user->user_lastname, + 'nice_name' => $user->user_nicename, + 'display_name' => $user->display_name, + 'avatar_url' => get_avatar_url( $user->ID ), + 'stores' => $this->stores_service->get_stores(), + ); + + return $data; } - /** - * Generate a JWT token for the provided user - * - * @param WP_User $user - * - * @return string|WP_Error - */ - /** - * Generate a JWT token for the provided user - * - * @param WP_User $user - * - * @return string|WP_Error - */ - public function generate_token( WP_User $user ) { - // First thing, check the secret key if not exist return a error - if ( ! $this->get_secret_key() ) { - return new WP_Error( - 'woocommerce_pos_jwt_auth_bad_config', - __( 'JWT is not configured properly, please contact the admin', 'woocommerce-pos' ), - array( - 'status' => 403, - ) - ); - } - - /** Valid credentials, the user exists create the according Token */ - $issued_at = time(); - - /** - * Filters the JWT issued at time. - * - * @param {string} $issued_at - * @returns {string} Issued at time - * @since 1.0.0 - * @hook woocommerce_pos_jwt_auth_not_before - */ - $not_before = apply_filters( 'woocommerce_pos_jwt_auth_not_before', $issued_at, $issued_at ); - - /** - * Filters the JWT expire time. - * - * @param {string} $issued_at - * @returns {string} Expire time - * @since 1.0.0 - * @hook woocommerce_pos_jwt_auth_expire - */ - $expire = apply_filters( 'woocommerce_pos_jwt_auth_expire', $issued_at + ( DAY_IN_SECONDS * 7 ), $issued_at ); - - $token = array( - 'iss' => get_bloginfo( 'url' ), - 'iat' => $issued_at, - 'nbf' => $not_before, - 'exp' => $expire, - 'data' => array( - 'user' => array( - 'id' => $user->data->ID, - ), - ), - ); - - /** - * Let the user modify the token data before the sign. - * - * @param {string} $token - * @param {WP_User} $user - * @returns {string} Token - * @since 1.0.0 - * @hook woocommerce_pos_jwt_auth_token_before_sign - */ - $token = FirebaseJWT::encode( apply_filters( 'woocommerce_pos_jwt_auth_token_before_sign', $token, $user ), $this->get_secret_key(), 'HS256' ); - - return $token; - } - - - /** - * Get user's data - * - * @param WP_User $user - * - * @return array - */ - public function get_user_data( WP_User $user ): array { - $data = array( - 'uuid' => $this->get_user_uuid( $user ), - 'id' => $user->ID, - 'jwt' => $this->generate_token( $user ), - 'username' => $user->user_login, - 'email' => $user->user_email, - 'first_name' => $user->user_firstname, - 'last_name' => $user->user_lastname, - 'nice_name' => $user->user_nicename, - 'display_name' => $user->display_name, - 'avatar_url' => get_avatar_url( $user->ID ), - 'stores' => $this->stores_service->get_stores(), - ); - - return $data; - } - - /** - * Note: usermeta is shared across all sites in a network, this can cause issues in the POS. - * We need to make sure that the user uuid is unique per site. - * - * @param WP_User $user - * @return string - */ - private function get_user_uuid( WP_User $user ): string { - $meta_key = '_woocommerce_pos_uuid'; - - if ( function_exists( 'is_multisite' ) && is_multisite() ) { - $meta_key = $meta_key . '_' . get_current_blog_id(); - } - - $uuid = get_user_meta( $user->ID, $meta_key, true ); - if ( ! $uuid ) { - $uuid = Uuid::uuid4()->toString(); - update_user_meta( $user->ID, $meta_key, $uuid ); - } - - return $uuid; - } - - /** - * Revoke JWT Token - */ - public function revoke_token(): void { - // Implementation - } - - /** - * Refresh JWT Token. - */ - public function refresh_token(): void { - // Implementation - } + /** + * Note: usermeta is shared across all sites in a network, this can cause issues in the POS. + * We need to make sure that the user uuid is unique per site. + * + * @param WP_User $user + * @return string + */ + private function get_user_uuid( WP_User $user ): string { + $meta_key = '_woocommerce_pos_uuid'; + + if ( function_exists( 'is_multisite' ) && is_multisite() ) { + $meta_key = $meta_key . '_' . get_current_blog_id(); + } + + $uuid = get_user_meta( $user->ID, $meta_key, true ); + if ( ! $uuid ) { + $uuid = Uuid::uuid4()->toString(); + update_user_meta( $user->ID, $meta_key, $uuid ); + } + + return $uuid; + } + + /** + * Revoke JWT Token + */ + public function revoke_token(): void { + // Implementation + } + + /** + * Refresh JWT Token. + */ + public function refresh_token(): void { + // Implementation + } } diff --git a/includes/wcpos-functions.php b/includes/wcpos-functions.php index 8bf09d9..6110f59 100644 --- a/includes/wcpos-functions.php +++ b/includes/wcpos-functions.php @@ -174,7 +174,7 @@ function woocommerce_pos_locate_template( $template = '' ) { } // Echo a message or handle the error as needed if the file path does not exist - echo "The template file '" . esc_html($filtered_path) . "' does not exist."; + echo "The template file '" . esc_html( $filtered_path ) . "' does not exist."; return null; } diff --git a/php-scoper/composer.json b/php-scoper/composer.json new file mode 100644 index 0000000..8f88f5b --- /dev/null +++ b/php-scoper/composer.json @@ -0,0 +1,23 @@ +{ + "require-dev": { + "humbug/php-scoper": "0.13.10" + }, + "require": { + "php": ">=7.4", + "ext-json": "*", + "firebase/php-jwt": "v6.9.0", + "ramsey/uuid": "4.2.3", + "salesforce/handlebars-php": "3.0.1", + "vlucas/phpdotenv": "v5.5.0", + "yahnis-elsts/plugin-update-checker": "v5.2" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "platform": { + "php": "7.4" + }, + "preferred-install": "dist", + "sort-packages": true + } +} diff --git a/php-scoper/config.php b/php-scoper/config.php new file mode 100644 index 0000000..e10e3df --- /dev/null +++ b/php-scoper/config.php @@ -0,0 +1,43 @@ + 'WCPOS\Vendor', + + // Finders: Locate files that need to be scoped. + 'finders' => array( + // Finder for the firebase/php-jwt library. + Finder::create()->files() + ->in( 'vendor/firebase/php-jwt' ) + ->name( '*.php' ), // Scope only PHP files. + + // Add Finder for yahnis-elsts/plugin-update-checker + Finder::create()->files() + ->in( 'vendor/yahnis-elsts/plugin-update-checker' ) + ->name( '*.php' ), // Scope only PHP files. + + // Add Finder for ramsey/uuid + // NOTE: UUID has too many dependencies to scope, so we'll just leave it alone. + // Finder::create()->files() + // ->in( 'vendor/ramsey/uuid' ) + // ->name( '*.php' ), // Scope only PHP files. + ), + + // 'patchers' are used to transform the code after it has been scoped. + // Define any necessary patchers below. For a minimal setup, this might not be needed. + 'patchers' => array( + // Example patcher (you can modify or remove this) + function ( string $filePath, string $prefix, string $content ) { + // Modify $content as needed or return it unchanged. + return $content; + }, + ), + + // 'whitelist' can be used to specify classes, functions, and constants + // that should not be prefixed (i.e., left in the global scope). + 'whitelist' => array( + // Example: 'YourNamespacePrefix\Firebase\JWT\*', + ), +); diff --git a/vendor_prefixed/firebase/php-jwt/src/BeforeValidException.php b/vendor_prefixed/firebase/php-jwt/src/BeforeValidException.php new file mode 100644 index 0000000..63f27c8 --- /dev/null +++ b/vendor_prefixed/firebase/php-jwt/src/BeforeValidException.php @@ -0,0 +1,16 @@ +payload = $payload; + } + public function getPayload() : object + { + return $this->payload; + } +} diff --git a/vendor_prefixed/firebase/php-jwt/src/CachedKeySet.php b/vendor_prefixed/firebase/php-jwt/src/CachedKeySet.php new file mode 100644 index 0000000..87c8aa9 --- /dev/null +++ b/vendor_prefixed/firebase/php-jwt/src/CachedKeySet.php @@ -0,0 +1,226 @@ + + */ +class CachedKeySet implements \ArrayAccess +{ + /** + * @var string + */ + private $jwksUri; + /** + * @var ClientInterface + */ + private $httpClient; + /** + * @var RequestFactoryInterface + */ + private $httpFactory; + /** + * @var CacheItemPoolInterface + */ + private $cache; + /** + * @var ?int + */ + private $expiresAfter; + /** + * @var ?CacheItemInterface + */ + private $cacheItem; + /** + * @var array> + */ + private $keySet; + /** + * @var string + */ + private $cacheKey; + /** + * @var string + */ + private $cacheKeyPrefix = 'jwks'; + /** + * @var int + */ + private $maxKeyLength = 64; + /** + * @var bool + */ + private $rateLimit; + /** + * @var string + */ + private $rateLimitCacheKey; + /** + * @var int + */ + private $maxCallsPerMinute = 10; + /** + * @var string|null + */ + private $defaultAlg; + public function __construct(string $jwksUri, \WCPOS\Vendor\Psr\Http\Client\ClientInterface $httpClient, \WCPOS\Vendor\Psr\Http\Message\RequestFactoryInterface $httpFactory, \WCPOS\Vendor\Psr\Cache\CacheItemPoolInterface $cache, int $expiresAfter = null, bool $rateLimit = \false, string $defaultAlg = null) + { + $this->jwksUri = $jwksUri; + $this->httpClient = $httpClient; + $this->httpFactory = $httpFactory; + $this->cache = $cache; + $this->expiresAfter = $expiresAfter; + $this->rateLimit = $rateLimit; + $this->defaultAlg = $defaultAlg; + $this->setCacheKeys(); + } + /** + * @param string $keyId + * @return Key + */ + public function offsetGet($keyId) : \WCPOS\Vendor\Firebase\JWT\Key + { + if (!$this->keyIdExists($keyId)) { + throw new \OutOfBoundsException('Key ID not found'); + } + return \WCPOS\Vendor\Firebase\JWT\JWK::parseKey($this->keySet[$keyId], $this->defaultAlg); + } + /** + * @param string $keyId + * @return bool + */ + public function offsetExists($keyId) : bool + { + return $this->keyIdExists($keyId); + } + /** + * @param string $offset + * @param Key $value + */ + public function offsetSet($offset, $value) : void + { + throw new \LogicException('Method not implemented'); + } + /** + * @param string $offset + */ + public function offsetUnset($offset) : void + { + throw new \LogicException('Method not implemented'); + } + /** + * @return array + */ + private function formatJwksForCache(string $jwks) : array + { + $jwks = \json_decode($jwks, \true); + if (!isset($jwks['keys'])) { + throw new \UnexpectedValueException('"keys" member must exist in the JWK Set'); + } + if (empty($jwks['keys'])) { + throw new \InvalidArgumentException('JWK Set did not contain any keys'); + } + $keys = []; + foreach ($jwks['keys'] as $k => $v) { + $kid = isset($v['kid']) ? $v['kid'] : $k; + $keys[(string) $kid] = $v; + } + return $keys; + } + private function keyIdExists(string $keyId) : bool + { + if (null === $this->keySet) { + $item = $this->getCacheItem(); + // Try to load keys from cache + if ($item->isHit()) { + // item found! retrieve it + $this->keySet = $item->get(); + // If the cached item is a string, the JWKS response was cached (previous behavior). + // Parse this into expected format array instead. + if (\is_string($this->keySet)) { + $this->keySet = $this->formatJwksForCache($this->keySet); + } + } + } + if (!isset($this->keySet[$keyId])) { + if ($this->rateLimitExceeded()) { + return \false; + } + $request = $this->httpFactory->createRequest('GET', $this->jwksUri); + $jwksResponse = $this->httpClient->sendRequest($request); + if ($jwksResponse->getStatusCode() !== 200) { + throw new \UnexpectedValueException(\sprintf('HTTP Error: %d %s for URI "%s"', $jwksResponse->getStatusCode(), $jwksResponse->getReasonPhrase(), $this->jwksUri), $jwksResponse->getStatusCode()); + } + $this->keySet = $this->formatJwksForCache((string) $jwksResponse->getBody()); + if (!isset($this->keySet[$keyId])) { + return \false; + } + $item = $this->getCacheItem(); + $item->set($this->keySet); + if ($this->expiresAfter) { + $item->expiresAfter($this->expiresAfter); + } + $this->cache->save($item); + } + return \true; + } + private function rateLimitExceeded() : bool + { + if (!$this->rateLimit) { + return \false; + } + $cacheItem = $this->cache->getItem($this->rateLimitCacheKey); + if (!$cacheItem->isHit()) { + $cacheItem->expiresAfter(1); + // # of calls are cached each minute + } + $callsPerMinute = (int) $cacheItem->get(); + if (++$callsPerMinute > $this->maxCallsPerMinute) { + return \true; + } + $cacheItem->set($callsPerMinute); + $this->cache->save($cacheItem); + return \false; + } + private function getCacheItem() : \WCPOS\Vendor\Psr\Cache\CacheItemInterface + { + if (\is_null($this->cacheItem)) { + $this->cacheItem = $this->cache->getItem($this->cacheKey); + } + return $this->cacheItem; + } + private function setCacheKeys() : void + { + if (empty($this->jwksUri)) { + throw new \RuntimeException('JWKS URI is empty'); + } + // ensure we do not have illegal characters + $key = \preg_replace('|[^a-zA-Z0-9_\\.!]|', '', $this->jwksUri); + // add prefix + $key = $this->cacheKeyPrefix . $key; + // Hash keys if they exceed $maxKeyLength of 64 + if (\strlen($key) > $this->maxKeyLength) { + $key = \substr(\hash('sha256', $key), 0, $this->maxKeyLength); + } + $this->cacheKey = $key; + if ($this->rateLimit) { + // add prefix + $rateLimitKey = $this->cacheKeyPrefix . 'ratelimit' . $key; + // Hash keys if they exceed $maxKeyLength of 64 + if (\strlen($rateLimitKey) > $this->maxKeyLength) { + $rateLimitKey = \substr(\hash('sha256', $rateLimitKey), 0, $this->maxKeyLength); + } + $this->rateLimitCacheKey = $rateLimitKey; + } + } +} diff --git a/vendor_prefixed/firebase/php-jwt/src/ExpiredException.php b/vendor_prefixed/firebase/php-jwt/src/ExpiredException.php new file mode 100644 index 0000000..b5338b0 --- /dev/null +++ b/vendor_prefixed/firebase/php-jwt/src/ExpiredException.php @@ -0,0 +1,16 @@ +payload = $payload; + } + public function getPayload() : object + { + return $this->payload; + } +} diff --git a/vendor_prefixed/firebase/php-jwt/src/JWK.php b/vendor_prefixed/firebase/php-jwt/src/JWK.php new file mode 100644 index 0000000..87c87b4 --- /dev/null +++ b/vendor_prefixed/firebase/php-jwt/src/JWK.php @@ -0,0 +1,267 @@ + + * @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD + * @link https://github.com/firebase/php-jwt + */ +class JWK +{ + private const OID = '1.2.840.10045.2.1'; + private const ASN1_OBJECT_IDENTIFIER = 0x6; + private const ASN1_SEQUENCE = 0x10; + // also defined in JWT + private const ASN1_BIT_STRING = 0x3; + private const EC_CURVES = [ + 'P-256' => '1.2.840.10045.3.1.7', + // Len: 64 + 'secp256k1' => '1.3.132.0.10', + // Len: 64 + 'P-384' => '1.3.132.0.34', + ]; + // For keys with "kty" equal to "OKP" (Octet Key Pair), the "crv" parameter must contain the key subtype. + // This library supports the following subtypes: + private const OKP_SUBTYPES = ['Ed25519' => \true]; + /** + * Parse a set of JWK keys + * + * @param array $jwks The JSON Web Key Set as an associative array + * @param string $defaultAlg The algorithm for the Key object if "alg" is not set in the + * JSON Web Key Set + * + * @return array An associative array of key IDs (kid) to Key objects + * + * @throws InvalidArgumentException Provided JWK Set is empty + * @throws UnexpectedValueException Provided JWK Set was invalid + * @throws DomainException OpenSSL failure + * + * @uses parseKey + */ + public static function parseKeySet(array $jwks, string $defaultAlg = null) : array + { + $keys = []; + if (!isset($jwks['keys'])) { + throw new \UnexpectedValueException('"keys" member must exist in the JWK Set'); + } + if (empty($jwks['keys'])) { + throw new \InvalidArgumentException('JWK Set did not contain any keys'); + } + foreach ($jwks['keys'] as $k => $v) { + $kid = isset($v['kid']) ? $v['kid'] : $k; + if ($key = self::parseKey($v, $defaultAlg)) { + $keys[(string) $kid] = $key; + } + } + if (0 === \count($keys)) { + throw new \UnexpectedValueException('No supported algorithms found in JWK Set'); + } + return $keys; + } + /** + * Parse a JWK key + * + * @param array $jwk An individual JWK + * @param string $defaultAlg The algorithm for the Key object if "alg" is not set in the + * JSON Web Key Set + * + * @return Key The key object for the JWK + * + * @throws InvalidArgumentException Provided JWK is empty + * @throws UnexpectedValueException Provided JWK was invalid + * @throws DomainException OpenSSL failure + * + * @uses createPemFromModulusAndExponent + */ + public static function parseKey(array $jwk, string $defaultAlg = null) : ?\WCPOS\Vendor\Firebase\JWT\Key + { + if (empty($jwk)) { + throw new \InvalidArgumentException('JWK must not be empty'); + } + if (!isset($jwk['kty'])) { + throw new \UnexpectedValueException('JWK must contain a "kty" parameter'); + } + if (!isset($jwk['alg'])) { + if (\is_null($defaultAlg)) { + // The "alg" parameter is optional in a KTY, but an algorithm is required + // for parsing in this library. Use the $defaultAlg parameter when parsing the + // key set in order to prevent this error. + // @see https://datatracker.ietf.org/doc/html/rfc7517#section-4.4 + throw new \UnexpectedValueException('JWK must contain an "alg" parameter'); + } + $jwk['alg'] = $defaultAlg; + } + switch ($jwk['kty']) { + case 'RSA': + if (!empty($jwk['d'])) { + throw new \UnexpectedValueException('RSA private keys are not supported'); + } + if (!isset($jwk['n']) || !isset($jwk['e'])) { + throw new \UnexpectedValueException('RSA keys must contain values for both "n" and "e"'); + } + $pem = self::createPemFromModulusAndExponent($jwk['n'], $jwk['e']); + $publicKey = \openssl_pkey_get_public($pem); + if (\false === $publicKey) { + throw new \DomainException('OpenSSL error: ' . \openssl_error_string()); + } + return new \WCPOS\Vendor\Firebase\JWT\Key($publicKey, $jwk['alg']); + case 'EC': + if (isset($jwk['d'])) { + // The key is actually a private key + throw new \UnexpectedValueException('Key data must be for a public key'); + } + if (empty($jwk['crv'])) { + throw new \UnexpectedValueException('crv not set'); + } + if (!isset(self::EC_CURVES[$jwk['crv']])) { + throw new \DomainException('Unrecognised or unsupported EC curve'); + } + if (empty($jwk['x']) || empty($jwk['y'])) { + throw new \UnexpectedValueException('x and y not set'); + } + $publicKey = self::createPemFromCrvAndXYCoordinates($jwk['crv'], $jwk['x'], $jwk['y']); + return new \WCPOS\Vendor\Firebase\JWT\Key($publicKey, $jwk['alg']); + case 'OKP': + if (isset($jwk['d'])) { + // The key is actually a private key + throw new \UnexpectedValueException('Key data must be for a public key'); + } + if (!isset($jwk['crv'])) { + throw new \UnexpectedValueException('crv not set'); + } + if (empty(self::OKP_SUBTYPES[$jwk['crv']])) { + throw new \DomainException('Unrecognised or unsupported OKP key subtype'); + } + if (empty($jwk['x'])) { + throw new \UnexpectedValueException('x not set'); + } + // This library works internally with EdDSA keys (Ed25519) encoded in standard base64. + $publicKey = \WCPOS\Vendor\Firebase\JWT\JWT::convertBase64urlToBase64($jwk['x']); + return new \WCPOS\Vendor\Firebase\JWT\Key($publicKey, $jwk['alg']); + default: + break; + } + return null; + } + /** + * Converts the EC JWK values to pem format. + * + * @param string $crv The EC curve (only P-256 & P-384 is supported) + * @param string $x The EC x-coordinate + * @param string $y The EC y-coordinate + * + * @return string + */ + private static function createPemFromCrvAndXYCoordinates(string $crv, string $x, string $y) : string + { + $pem = self::encodeDER(self::ASN1_SEQUENCE, self::encodeDER(self::ASN1_SEQUENCE, self::encodeDER(self::ASN1_OBJECT_IDENTIFIER, self::encodeOID(self::OID)) . self::encodeDER(self::ASN1_OBJECT_IDENTIFIER, self::encodeOID(self::EC_CURVES[$crv]))) . self::encodeDER(self::ASN1_BIT_STRING, \chr(0x0) . \chr(0x4) . \WCPOS\Vendor\Firebase\JWT\JWT::urlsafeB64Decode($x) . \WCPOS\Vendor\Firebase\JWT\JWT::urlsafeB64Decode($y))); + return \sprintf("-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n", \wordwrap(\base64_encode($pem), 64, "\n", \true)); + } + /** + * Create a public key represented in PEM format from RSA modulus and exponent information + * + * @param string $n The RSA modulus encoded in Base64 + * @param string $e The RSA exponent encoded in Base64 + * + * @return string The RSA public key represented in PEM format + * + * @uses encodeLength + */ + private static function createPemFromModulusAndExponent(string $n, string $e) : string + { + $mod = \WCPOS\Vendor\Firebase\JWT\JWT::urlsafeB64Decode($n); + $exp = \WCPOS\Vendor\Firebase\JWT\JWT::urlsafeB64Decode($e); + $modulus = \pack('Ca*a*', 2, self::encodeLength(\strlen($mod)), $mod); + $publicExponent = \pack('Ca*a*', 2, self::encodeLength(\strlen($exp)), $exp); + $rsaPublicKey = \pack('Ca*a*a*', 48, self::encodeLength(\strlen($modulus) + \strlen($publicExponent)), $modulus, $publicExponent); + // sequence(oid(1.2.840.113549.1.1.1), null)) = rsaEncryption. + $rsaOID = \pack('H*', '300d06092a864886f70d0101010500'); + // hex version of MA0GCSqGSIb3DQEBAQUA + $rsaPublicKey = \chr(0) . $rsaPublicKey; + $rsaPublicKey = \chr(3) . self::encodeLength(\strlen($rsaPublicKey)) . $rsaPublicKey; + $rsaPublicKey = \pack('Ca*a*', 48, self::encodeLength(\strlen($rsaOID . $rsaPublicKey)), $rsaOID . $rsaPublicKey); + return "-----BEGIN PUBLIC KEY-----\r\n" . \chunk_split(\base64_encode($rsaPublicKey), 64) . '-----END PUBLIC KEY-----'; + } + /** + * DER-encode the length + * + * DER supports lengths up to (2**8)**127, however, we'll only support lengths up to (2**8)**4. See + * {@link http://itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf#p=13 X.690 paragraph 8.1.3} for more information. + * + * @param int $length + * @return string + */ + private static function encodeLength(int $length) : string + { + if ($length <= 0x7f) { + return \chr($length); + } + $temp = \ltrim(\pack('N', $length), \chr(0)); + return \pack('Ca*', 0x80 | \strlen($temp), $temp); + } + /** + * Encodes a value into a DER object. + * Also defined in Firebase\JWT\JWT + * + * @param int $type DER tag + * @param string $value the value to encode + * @return string the encoded object + */ + private static function encodeDER(int $type, string $value) : string + { + $tag_header = 0; + if ($type === self::ASN1_SEQUENCE) { + $tag_header |= 0x20; + } + // Type + $der = \chr($tag_header | $type); + // Length + $der .= \chr(\strlen($value)); + return $der . $value; + } + /** + * Encodes a string into a DER-encoded OID. + * + * @param string $oid the OID string + * @return string the binary DER-encoded OID + */ + private static function encodeOID(string $oid) : string + { + $octets = \explode('.', $oid); + // Get the first octet + $first = (int) \array_shift($octets); + $second = (int) \array_shift($octets); + $oid = \chr($first * 40 + $second); + // Iterate over subsequent octets + foreach ($octets as $octet) { + if ($octet == 0) { + $oid .= \chr(0x0); + continue; + } + $bin = ''; + while ($octet) { + $bin .= \chr(0x80 | $octet & 0x7f); + $octet >>= 7; + } + $bin[0] = $bin[0] & \chr(0x7f); + // Convert to big endian if necessary + if (\pack('V', 65534) == \pack('L', 65534)) { + $oid .= \strrev($bin); + } else { + $oid .= $bin; + } + } + return $oid; + } +} diff --git a/vendor_prefixed/firebase/php-jwt/src/JWT.php b/vendor_prefixed/firebase/php-jwt/src/JWT.php new file mode 100644 index 0000000..ad2da08 --- /dev/null +++ b/vendor_prefixed/firebase/php-jwt/src/JWT.php @@ -0,0 +1,571 @@ + + * @author Anant Narayanan + * @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD + * @link https://github.com/firebase/php-jwt + */ +class JWT +{ + private const ASN1_INTEGER = 0x2; + private const ASN1_SEQUENCE = 0x10; + private const ASN1_BIT_STRING = 0x3; + /** + * When checking nbf, iat or expiration times, + * we want to provide some extra leeway time to + * account for clock skew. + * + * @var int + */ + public static $leeway = 0; + /** + * Allow the current timestamp to be specified. + * Useful for fixing a value within unit testing. + * Will default to PHP time() value if null. + * + * @var ?int + */ + public static $timestamp = null; + /** + * @var array + */ + public static $supported_algs = ['ES384' => ['openssl', 'SHA384'], 'ES256' => ['openssl', 'SHA256'], 'ES256K' => ['openssl', 'SHA256'], 'HS256' => ['hash_hmac', 'SHA256'], 'HS384' => ['hash_hmac', 'SHA384'], 'HS512' => ['hash_hmac', 'SHA512'], 'RS256' => ['openssl', 'SHA256'], 'RS384' => ['openssl', 'SHA384'], 'RS512' => ['openssl', 'SHA512'], 'EdDSA' => ['sodium_crypto', 'EdDSA']]; + /** + * Decodes a JWT string into a PHP object. + * + * @param string $jwt The JWT + * @param Key|ArrayAccess|array $keyOrKeyArray The Key or associative array of key IDs + * (kid) to Key objects. + * If the algorithm used is asymmetric, this is + * the public key. + * Each Key object contains an algorithm and + * matching key. + * Supported algorithms are 'ES384','ES256', + * 'HS256', 'HS384', 'HS512', 'RS256', 'RS384' + * and 'RS512'. + * @param stdClass $headers Optional. Populates stdClass with headers. + * + * @return stdClass The JWT's payload as a PHP object + * + * @throws InvalidArgumentException Provided key/key-array was empty or malformed + * @throws DomainException Provided JWT is malformed + * @throws UnexpectedValueException Provided JWT was invalid + * @throws SignatureInvalidException Provided JWT was invalid because the signature verification failed + * @throws BeforeValidException Provided JWT is trying to be used before it's eligible as defined by 'nbf' + * @throws BeforeValidException Provided JWT is trying to be used before it's been created as defined by 'iat' + * @throws ExpiredException Provided JWT has since expired, as defined by the 'exp' claim + * + * @uses jsonDecode + * @uses urlsafeB64Decode + */ + public static function decode(string $jwt, $keyOrKeyArray, \stdClass &$headers = null) : \stdClass + { + // Validate JWT + $timestamp = \is_null(static::$timestamp) ? \time() : static::$timestamp; + if (empty($keyOrKeyArray)) { + throw new \InvalidArgumentException('Key may not be empty'); + } + $tks = \explode('.', $jwt); + if (\count($tks) !== 3) { + throw new \UnexpectedValueException('Wrong number of segments'); + } + list($headb64, $bodyb64, $cryptob64) = $tks; + $headerRaw = static::urlsafeB64Decode($headb64); + if (null === ($header = static::jsonDecode($headerRaw))) { + throw new \UnexpectedValueException('Invalid header encoding'); + } + if ($headers !== null) { + $headers = $header; + } + $payloadRaw = static::urlsafeB64Decode($bodyb64); + if (null === ($payload = static::jsonDecode($payloadRaw))) { + throw new \UnexpectedValueException('Invalid claims encoding'); + } + if (\is_array($payload)) { + // prevent PHP Fatal Error in edge-cases when payload is empty array + $payload = (object) $payload; + } + if (!$payload instanceof \stdClass) { + throw new \UnexpectedValueException('Payload must be a JSON object'); + } + $sig = static::urlsafeB64Decode($cryptob64); + if (empty($header->alg)) { + throw new \UnexpectedValueException('Empty algorithm'); + } + if (empty(static::$supported_algs[$header->alg])) { + throw new \UnexpectedValueException('Algorithm not supported'); + } + $key = self::getKey($keyOrKeyArray, \property_exists($header, 'kid') ? $header->kid : null); + // Check the algorithm + if (!self::constantTimeEquals($key->getAlgorithm(), $header->alg)) { + // See issue #351 + throw new \UnexpectedValueException('Incorrect key for this algorithm'); + } + if (\in_array($header->alg, ['ES256', 'ES256K', 'ES384'], \true)) { + // OpenSSL expects an ASN.1 DER sequence for ES256/ES256K/ES384 signatures + $sig = self::signatureToDER($sig); + } + if (!self::verify("{$headb64}.{$bodyb64}", $sig, $key->getKeyMaterial(), $header->alg)) { + throw new \WCPOS\Vendor\Firebase\JWT\SignatureInvalidException('Signature verification failed'); + } + // Check the nbf if it is defined. This is the time that the + // token can actually be used. If it's not yet that time, abort. + if (isset($payload->nbf) && \floor($payload->nbf) > $timestamp + static::$leeway) { + $ex = new \WCPOS\Vendor\Firebase\JWT\BeforeValidException('Cannot handle token with nbf prior to ' . \date(\DateTime::ISO8601, (int) $payload->nbf)); + $ex->setPayload($payload); + throw $ex; + } + // Check that this token has been created before 'now'. This prevents + // using tokens that have been created for later use (and haven't + // correctly used the nbf claim). + if (!isset($payload->nbf) && isset($payload->iat) && \floor($payload->iat) > $timestamp + static::$leeway) { + $ex = new \WCPOS\Vendor\Firebase\JWT\BeforeValidException('Cannot handle token with iat prior to ' . \date(\DateTime::ISO8601, (int) $payload->iat)); + $ex->setPayload($payload); + throw $ex; + } + // Check if this token has expired. + if (isset($payload->exp) && $timestamp - static::$leeway >= $payload->exp) { + $ex = new \WCPOS\Vendor\Firebase\JWT\ExpiredException('Expired token'); + $ex->setPayload($payload); + throw $ex; + } + return $payload; + } + /** + * Converts and signs a PHP array into a JWT string. + * + * @param array $payload PHP array + * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. + * @param string $alg Supported algorithms are 'ES384','ES256', 'ES256K', 'HS256', + * 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' + * @param string $keyId + * @param array $head An array with header elements to attach + * + * @return string A signed JWT + * + * @uses jsonEncode + * @uses urlsafeB64Encode + */ + public static function encode(array $payload, $key, string $alg, string $keyId = null, array $head = null) : string + { + $header = ['typ' => 'JWT', 'alg' => $alg]; + if ($keyId !== null) { + $header['kid'] = $keyId; + } + if (isset($head) && \is_array($head)) { + $header = \array_merge($head, $header); + } + $segments = []; + $segments[] = static::urlsafeB64Encode((string) static::jsonEncode($header)); + $segments[] = static::urlsafeB64Encode((string) static::jsonEncode($payload)); + $signing_input = \implode('.', $segments); + $signature = static::sign($signing_input, $key, $alg); + $segments[] = static::urlsafeB64Encode($signature); + return \implode('.', $segments); + } + /** + * Sign a string with a given key and algorithm. + * + * @param string $msg The message to sign + * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $key The secret key. + * @param string $alg Supported algorithms are 'EdDSA', 'ES384', 'ES256', 'ES256K', 'HS256', + * 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512' + * + * @return string An encrypted message + * + * @throws DomainException Unsupported algorithm or bad key was specified + */ + public static function sign(string $msg, $key, string $alg) : string + { + if (empty(static::$supported_algs[$alg])) { + throw new \DomainException('Algorithm not supported'); + } + list($function, $algorithm) = static::$supported_algs[$alg]; + switch ($function) { + case 'hash_hmac': + if (!\is_string($key)) { + throw new \InvalidArgumentException('key must be a string when using hmac'); + } + return \hash_hmac($algorithm, $msg, $key, \true); + case 'openssl': + $signature = ''; + $success = \openssl_sign($msg, $signature, $key, $algorithm); + // @phpstan-ignore-line + if (!$success) { + throw new \DomainException('OpenSSL unable to sign data'); + } + if ($alg === 'ES256' || $alg === 'ES256K') { + $signature = self::signatureFromDER($signature, 256); + } elseif ($alg === 'ES384') { + $signature = self::signatureFromDER($signature, 384); + } + return $signature; + case 'sodium_crypto': + if (!\function_exists('sodium_crypto_sign_detached')) { + throw new \DomainException('libsodium is not available'); + } + if (!\is_string($key)) { + throw new \InvalidArgumentException('key must be a string when using EdDSA'); + } + try { + // The last non-empty line is used as the key. + $lines = \array_filter(\explode("\n", $key)); + $key = \base64_decode((string) \end($lines)); + if (\strlen($key) === 0) { + throw new \DomainException('Key cannot be empty string'); + } + return \sodium_crypto_sign_detached($msg, $key); + } catch (\Exception $e) { + throw new \DomainException($e->getMessage(), 0, $e); + } + } + throw new \DomainException('Algorithm not supported'); + } + /** + * Verify a signature with the message, key and method. Not all methods + * are symmetric, so we must have a separate verify and sign method. + * + * @param string $msg The original message (header and body) + * @param string $signature The original signature + * @param string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate $keyMaterial For Ed*, ES*, HS*, a string key works. for RS*, must be an instance of OpenSSLAsymmetricKey + * @param string $alg The algorithm + * + * @return bool + * + * @throws DomainException Invalid Algorithm, bad key, or OpenSSL failure + */ + private static function verify(string $msg, string $signature, $keyMaterial, string $alg) : bool + { + if (empty(static::$supported_algs[$alg])) { + throw new \DomainException('Algorithm not supported'); + } + list($function, $algorithm) = static::$supported_algs[$alg]; + switch ($function) { + case 'openssl': + $success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); + // @phpstan-ignore-line + if ($success === 1) { + return \true; + } + if ($success === 0) { + return \false; + } + // returns 1 on success, 0 on failure, -1 on error. + throw new \DomainException('OpenSSL error: ' . \openssl_error_string()); + case 'sodium_crypto': + if (!\function_exists('sodium_crypto_sign_verify_detached')) { + throw new \DomainException('libsodium is not available'); + } + if (!\is_string($keyMaterial)) { + throw new \InvalidArgumentException('key must be a string when using EdDSA'); + } + try { + // The last non-empty line is used as the key. + $lines = \array_filter(\explode("\n", $keyMaterial)); + $key = \base64_decode((string) \end($lines)); + if (\strlen($key) === 0) { + throw new \DomainException('Key cannot be empty string'); + } + if (\strlen($signature) === 0) { + throw new \DomainException('Signature cannot be empty string'); + } + return \sodium_crypto_sign_verify_detached($signature, $msg, $key); + } catch (\Exception $e) { + throw new \DomainException($e->getMessage(), 0, $e); + } + case 'hash_hmac': + default: + if (!\is_string($keyMaterial)) { + throw new \InvalidArgumentException('key must be a string when using hmac'); + } + $hash = \hash_hmac($algorithm, $msg, $keyMaterial, \true); + return self::constantTimeEquals($hash, $signature); + } + } + /** + * Decode a JSON string into a PHP object. + * + * @param string $input JSON string + * + * @return mixed The decoded JSON string + * + * @throws DomainException Provided string was invalid JSON + */ + public static function jsonDecode(string $input) + { + $obj = \json_decode($input, \false, 512, \JSON_BIGINT_AS_STRING); + if ($errno = \json_last_error()) { + self::handleJsonError($errno); + } elseif ($obj === null && $input !== 'null') { + throw new \DomainException('Null result with non-null input'); + } + return $obj; + } + /** + * Encode a PHP array into a JSON string. + * + * @param array $input A PHP array + * + * @return string JSON representation of the PHP array + * + * @throws DomainException Provided object could not be encoded to valid JSON + */ + public static function jsonEncode(array $input) : string + { + if (\PHP_VERSION_ID >= 50400) { + $json = \json_encode($input, \JSON_UNESCAPED_SLASHES); + } else { + // PHP 5.3 only + $json = \json_encode($input); + } + if ($errno = \json_last_error()) { + self::handleJsonError($errno); + } elseif ($json === 'null') { + throw new \DomainException('Null result with non-null input'); + } + if ($json === \false) { + throw new \DomainException('Provided object could not be encoded to valid JSON'); + } + return $json; + } + /** + * Decode a string with URL-safe Base64. + * + * @param string $input A Base64 encoded string + * + * @return string A decoded string + * + * @throws InvalidArgumentException invalid base64 characters + */ + public static function urlsafeB64Decode(string $input) : string + { + return \base64_decode(self::convertBase64UrlToBase64($input)); + } + /** + * Convert a string in the base64url (URL-safe Base64) encoding to standard base64. + * + * @param string $input A Base64 encoded string with URL-safe characters (-_ and no padding) + * + * @return string A Base64 encoded string with standard characters (+/) and padding (=), when + * needed. + * + * @see https://www.rfc-editor.org/rfc/rfc4648 + */ + public static function convertBase64UrlToBase64(string $input) : string + { + $remainder = \strlen($input) % 4; + if ($remainder) { + $padlen = 4 - $remainder; + $input .= \str_repeat('=', $padlen); + } + return \strtr($input, '-_', '+/'); + } + /** + * Encode a string with URL-safe Base64. + * + * @param string $input The string you want encoded + * + * @return string The base64 encode of what you passed in + */ + public static function urlsafeB64Encode(string $input) : string + { + return \str_replace('=', '', \strtr(\base64_encode($input), '+/', '-_')); + } + /** + * Determine if an algorithm has been provided for each Key + * + * @param Key|ArrayAccess|array $keyOrKeyArray + * @param string|null $kid + * + * @throws UnexpectedValueException + * + * @return Key + */ + private static function getKey($keyOrKeyArray, ?string $kid) : \WCPOS\Vendor\Firebase\JWT\Key + { + if ($keyOrKeyArray instanceof \WCPOS\Vendor\Firebase\JWT\Key) { + return $keyOrKeyArray; + } + if (empty($kid) && $kid !== '0') { + throw new \UnexpectedValueException('"kid" empty, unable to lookup correct key'); + } + if ($keyOrKeyArray instanceof \WCPOS\Vendor\Firebase\JWT\CachedKeySet) { + // Skip "isset" check, as this will automatically refresh if not set + return $keyOrKeyArray[$kid]; + } + if (!isset($keyOrKeyArray[$kid])) { + throw new \UnexpectedValueException('"kid" invalid, unable to lookup correct key'); + } + return $keyOrKeyArray[$kid]; + } + /** + * @param string $left The string of known length to compare against + * @param string $right The user-supplied string + * @return bool + */ + public static function constantTimeEquals(string $left, string $right) : bool + { + if (\function_exists('hash_equals')) { + return \hash_equals($left, $right); + } + $len = \min(self::safeStrlen($left), self::safeStrlen($right)); + $status = 0; + for ($i = 0; $i < $len; $i++) { + $status |= \ord($left[$i]) ^ \ord($right[$i]); + } + $status |= self::safeStrlen($left) ^ self::safeStrlen($right); + return $status === 0; + } + /** + * Helper method to create a JSON error. + * + * @param int $errno An error number from json_last_error() + * + * @throws DomainException + * + * @return void + */ + private static function handleJsonError(int $errno) : void + { + $messages = [\JSON_ERROR_DEPTH => 'Maximum stack depth exceeded', \JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON', \JSON_ERROR_CTRL_CHAR => 'Unexpected control character found', \JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON', \JSON_ERROR_UTF8 => 'Malformed UTF-8 characters']; + throw new \DomainException(isset($messages[$errno]) ? $messages[$errno] : 'Unknown JSON error: ' . $errno); + } + /** + * Get the number of bytes in cryptographic strings. + * + * @param string $str + * + * @return int + */ + private static function safeStrlen(string $str) : int + { + if (\function_exists('mb_strlen')) { + return \mb_strlen($str, '8bit'); + } + return \strlen($str); + } + /** + * Convert an ECDSA signature to an ASN.1 DER sequence + * + * @param string $sig The ECDSA signature to convert + * @return string The encoded DER object + */ + private static function signatureToDER(string $sig) : string + { + // Separate the signature into r-value and s-value + $length = \max(1, (int) (\strlen($sig) / 2)); + list($r, $s) = \str_split($sig, $length); + // Trim leading zeros + $r = \ltrim($r, "\x00"); + $s = \ltrim($s, "\x00"); + // Convert r-value and s-value from unsigned big-endian integers to + // signed two's complement + if (\ord($r[0]) > 0x7f) { + $r = "\x00" . $r; + } + if (\ord($s[0]) > 0x7f) { + $s = "\x00" . $s; + } + return self::encodeDER(self::ASN1_SEQUENCE, self::encodeDER(self::ASN1_INTEGER, $r) . self::encodeDER(self::ASN1_INTEGER, $s)); + } + /** + * Encodes a value into a DER object. + * + * @param int $type DER tag + * @param string $value the value to encode + * + * @return string the encoded object + */ + private static function encodeDER(int $type, string $value) : string + { + $tag_header = 0; + if ($type === self::ASN1_SEQUENCE) { + $tag_header |= 0x20; + } + // Type + $der = \chr($tag_header | $type); + // Length + $der .= \chr(\strlen($value)); + return $der . $value; + } + /** + * Encodes signature from a DER object. + * + * @param string $der binary signature in DER format + * @param int $keySize the number of bits in the key + * + * @return string the signature + */ + private static function signatureFromDER(string $der, int $keySize) : string + { + // OpenSSL returns the ECDSA signatures as a binary ASN.1 DER SEQUENCE + list($offset, $_) = self::readDER($der); + list($offset, $r) = self::readDER($der, $offset); + list($offset, $s) = self::readDER($der, $offset); + // Convert r-value and s-value from signed two's compliment to unsigned + // big-endian integers + $r = \ltrim($r, "\x00"); + $s = \ltrim($s, "\x00"); + // Pad out r and s so that they are $keySize bits long + $r = \str_pad($r, $keySize / 8, "\x00", \STR_PAD_LEFT); + $s = \str_pad($s, $keySize / 8, "\x00", \STR_PAD_LEFT); + return $r . $s; + } + /** + * Reads binary DER-encoded data and decodes into a single object + * + * @param string $der the binary data in DER format + * @param int $offset the offset of the data stream containing the object + * to decode + * + * @return array{int, string|null} the new offset and the decoded object + */ + private static function readDER(string $der, int $offset = 0) : array + { + $pos = $offset; + $size = \strlen($der); + $constructed = \ord($der[$pos]) >> 5 & 0x1; + $type = \ord($der[$pos++]) & 0x1f; + // Length + $len = \ord($der[$pos++]); + if ($len & 0x80) { + $n = $len & 0x1f; + $len = 0; + while ($n-- && $pos < $size) { + $len = $len << 8 | \ord($der[$pos++]); + } + } + // Value + if ($type === self::ASN1_BIT_STRING) { + $pos++; + // Skip the first contents octet (padding indicator) + $data = \substr($der, $pos, $len - 1); + $pos += $len - 1; + } elseif (!$constructed) { + $data = \substr($der, $pos, $len); + $pos += $len; + } else { + $data = null; + } + return [$pos, $data]; + } +} diff --git a/vendor_prefixed/firebase/php-jwt/src/JWTExceptionWithPayloadInterface.php b/vendor_prefixed/firebase/php-jwt/src/JWTExceptionWithPayloadInterface.php new file mode 100644 index 0000000..c28679a --- /dev/null +++ b/vendor_prefixed/firebase/php-jwt/src/JWTExceptionWithPayloadInterface.php @@ -0,0 +1,20 @@ +keyMaterial = $keyMaterial; + $this->algorithm = $algorithm; + } + /** + * Return the algorithm valid for this key + * + * @return string + */ + public function getAlgorithm() : string + { + return $this->algorithm; + } + /** + * @return string|resource|OpenSSLAsymmetricKey|OpenSSLCertificate + */ + public function getKeyMaterial() + { + return $this->keyMaterial; + } +} diff --git a/vendor_prefixed/firebase/php-jwt/src/SignatureInvalidException.php b/vendor_prefixed/firebase/php-jwt/src/SignatureInvalidException.php new file mode 100644 index 0000000..db82bf3 --- /dev/null +++ b/vendor_prefixed/firebase/php-jwt/src/SignatureInvalidException.php @@ -0,0 +1,7 @@ +rootDir = \dirname(__FILE__) . '/'; + $namespaceWithSlash = __NAMESPACE__ . '\\'; + $this->prefix = $namespaceWithSlash; + $this->libraryDir = $this->rootDir . '../..'; + if (!self::isPhar()) { + $this->libraryDir = \realpath($this->libraryDir); + } + $this->libraryDir = $this->libraryDir . '/'; + //Usually, dependencies like Parsedown are in the global namespace, + //but if someone adds a custom namespace to the entire library, they + //will be in the same namespace as this class. + $isCustomNamespace = \substr($namespaceWithSlash, 0, \strlen(self::DEFAULT_NS_PREFIX)) !== self::DEFAULT_NS_PREFIX; + $libraryPrefix = $isCustomNamespace ? $namespaceWithSlash : ''; + $this->staticMap = array($libraryPrefix . 'PucReadmeParser' => 'vendor/PucReadmeParser.php', $libraryPrefix . 'Parsedown' => 'vendor/Parsedown.php'); + //Add the generic, major-version-only factory class to the static map. + $versionSeparatorPos = \strrpos(__NAMESPACE__, '\\v'); + if ($versionSeparatorPos !== \false) { + $versionSegment = \substr(__NAMESPACE__, $versionSeparatorPos + 1); + $pointPos = \strpos($versionSegment, 'p'); + if ($pointPos !== \false && $pointPos > 1) { + $majorVersionSegment = \substr($versionSegment, 0, $pointPos); + $majorVersionNs = __NAMESPACE__ . '\\' . $majorVersionSegment; + $this->staticMap[$majorVersionNs . '\\PucFactory'] = 'Puc/' . $majorVersionSegment . '/Factory.php'; + } + } + \spl_autoload_register(array($this, 'autoload')); + } + /** + * Determine if this file is running as part of a Phar archive. + * + * @return bool + */ + private static function isPhar() + { + //Check if the current file path starts with "phar://". + static $pharProtocol = 'phar://'; + return \substr(__FILE__, 0, \strlen($pharProtocol)) === $pharProtocol; + } + public function autoload($className) + { + if (isset($this->staticMap[$className]) && \file_exists($this->libraryDir . $this->staticMap[$className])) { + include $this->libraryDir . $this->staticMap[$className]; + return; + } + if (\strpos($className, $this->prefix) === 0) { + $path = \substr($className, \strlen($this->prefix)); + $path = \str_replace(array('_', '\\'), '/', $path); + $path = $this->rootDir . $path . '.php'; + if (\file_exists($path)) { + include $path; + } + } + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/DebugBar/Extension.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/DebugBar/Extension.php new file mode 100644 index 0000000..3282e72 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/DebugBar/Extension.php @@ -0,0 +1,163 @@ +updateChecker = $updateChecker; + if (isset($panelClass)) { + $this->panelClass = $panelClass; + } + if (\strpos($this->panelClass, '\\') === \false) { + $this->panelClass = __NAMESPACE__ . '\\' . $this->panelClass; + } + add_filter('debug_bar_panels', array($this, 'addDebugBarPanel')); + add_action('debug_bar_enqueue_scripts', array($this, 'enqueuePanelDependencies')); + add_action('wp_ajax_puc_v5_debug_check_now', array($this, 'ajaxCheckNow')); + } + /** + * Register the PUC Debug Bar panel. + * + * @param array $panels + * @return array + */ + public function addDebugBarPanel($panels) + { + if ($this->updateChecker->userCanInstallUpdates()) { + $panels[] = new $this->panelClass($this->updateChecker); + } + return $panels; + } + /** + * Enqueue our Debug Bar scripts and styles. + */ + public function enqueuePanelDependencies() + { + wp_enqueue_style('puc-debug-bar-style-v5', $this->getLibraryUrl("/css/puc-debug-bar.css"), array('debug-bar'), '20221008'); + wp_enqueue_script('puc-debug-bar-js-v5', $this->getLibraryUrl("/js/debug-bar.js"), array('jquery'), '20221008'); + } + /** + * Run an update check and output the result. Useful for making sure that + * the update checking process works as expected. + */ + public function ajaxCheckNow() + { + //phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is checked in preAjaxRequest(). + if (!isset($_POST['uid']) || $_POST['uid'] !== $this->updateChecker->getUniqueName('uid')) { + return; + } + $this->preAjaxRequest(); + $update = $this->updateChecker->checkForUpdates(); + if ($update !== null) { + echo "An update is available:"; + //phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r -- For debugging output. + echo '
', esc_html(\print_r($update, \true)), '
'; + } else { + echo 'No updates found.'; + } + $errors = $this->updateChecker->getLastRequestApiErrors(); + if (!empty($errors)) { + \printf('

The update checker encountered %d API error%s.

', \count($errors), \count($errors) > 1 ? 's' : ''); + foreach (\array_values($errors) as $num => $item) { + $wpError = $item['error']; + /** @var \WP_Error $wpError */ + \printf('

%d) %s

', \intval($num + 1), esc_html($wpError->get_error_message())); + echo '
'; + \printf('
Error code:
%s
', esc_html($wpError->get_error_code())); + if (isset($item['url'])) { + \printf('
Requested URL:
%s
', esc_html($item['url'])); + } + if (isset($item['httpResponse'])) { + if (is_wp_error($item['httpResponse'])) { + $httpError = $item['httpResponse']; + /** @var \WP_Error $httpError */ + \printf('
WordPress HTTP API error:
%s (%s)
', esc_html($httpError->get_error_message()), esc_html($httpError->get_error_code())); + } else { + //Status code. + \printf('
HTTP status:
%d %s
', esc_html(wp_remote_retrieve_response_code($item['httpResponse'])), esc_html(wp_remote_retrieve_response_message($item['httpResponse']))); + //Headers. + echo '
Response headers:
';
+                            foreach (wp_remote_retrieve_headers($item['httpResponse']) as $name => $value) {
+                                \printf("%s: %s\n", esc_html($name), esc_html($value));
+                            }
+                            echo '
'; + //Body. + $body = wp_remote_retrieve_body($item['httpResponse']); + if ($body === '') { + $body = '(Empty response.)'; + } else { + if (\strlen($body) > self::RESPONSE_BODY_LENGTH_LIMIT) { + $length = \strlen($body); + $body = \substr($body, 0, self::RESPONSE_BODY_LENGTH_LIMIT) . \sprintf("\n(Long string truncated. Total length: %d bytes.)", $length); + } + } + \printf('
Response body:
%s
', esc_html($body)); + } + } + echo '
'; + } + } + exit; + } + /** + * Check access permissions and enable error display (for debugging). + */ + protected function preAjaxRequest() + { + if (!$this->updateChecker->userCanInstallUpdates()) { + die('Access denied'); + } + check_ajax_referer('puc-ajax'); + //phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_error_reporting -- Part of a debugging feature. + \error_reporting(\E_ALL); + //phpcs:ignore WordPress.PHP.IniSet.display_errors_Blacklisted + @\ini_set('display_errors', 'On'); + } + /** + * Remove hooks that were added by this extension. + */ + public function removeHooks() + { + remove_filter('debug_bar_panels', array($this, 'addDebugBarPanel')); + remove_action('debug_bar_enqueue_scripts', array($this, 'enqueuePanelDependencies')); + remove_action('wp_ajax_puc_v5_debug_check_now', array($this, 'ajaxCheckNow')); + } + /** + * @param string $filePath + * @return string + */ + private function getLibraryUrl($filePath) + { + $absolutePath = \realpath(\dirname(__FILE__) . '/../../../' . \ltrim($filePath, '/')); + //Where is the library located inside the WordPress directory structure? + $absolutePath = \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\PucFactory::normalizePath($absolutePath); + $pluginDir = \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\PucFactory::normalizePath(WP_PLUGIN_DIR); + $muPluginDir = \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\PucFactory::normalizePath(WPMU_PLUGIN_DIR); + $themeDir = \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\PucFactory::normalizePath(get_theme_root()); + if (\strpos($absolutePath, $pluginDir) === 0 || \strpos($absolutePath, $muPluginDir) === 0) { + //It's part of a plugin. + return plugins_url(\basename($absolutePath), $absolutePath); + } else { + if (\strpos($absolutePath, $themeDir) === 0) { + //It's part of a theme. + $relativePath = \substr($absolutePath, \strlen($themeDir) + 1); + $template = \substr($relativePath, 0, \strpos($relativePath, '/')); + $baseUrl = get_theme_root_uri($template); + if (!empty($baseUrl) && $relativePath) { + return $baseUrl . '/' . $relativePath; + } + } + } + return ''; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/DebugBar/Panel.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/DebugBar/Panel.php new file mode 100644 index 0000000..38322be --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/DebugBar/Panel.php @@ -0,0 +1,141 @@ +'; + public function __construct($updateChecker) + { + $this->updateChecker = $updateChecker; + $title = \sprintf('PUC (%s)', esc_attr($this->updateChecker->getUniqueName('uid')), $this->updateChecker->slug); + parent::__construct($title); + } + public function render() + { + \printf('
', esc_attr($this->updateChecker->getUniqueName('debug-bar-panel')), esc_attr($this->updateChecker->slug), esc_attr($this->updateChecker->getUniqueName('uid')), esc_attr(wp_create_nonce('puc-ajax'))); + $this->displayConfiguration(); + $this->displayStatus(); + $this->displayCurrentUpdate(); + echo '
'; + } + private function displayConfiguration() + { + echo '

Configuration

'; + echo ''; + $this->displayConfigHeader(); + $this->row('Slug', \htmlentities($this->updateChecker->slug)); + $this->row('DB option', \htmlentities($this->updateChecker->optionName)); + $requestInfoButton = $this->getMetadataButton(); + $this->row('Metadata URL', \htmlentities($this->updateChecker->metadataUrl) . ' ' . $requestInfoButton . $this->responseBox); + $scheduler = $this->updateChecker->scheduler; + if ($scheduler->checkPeriod > 0) { + $this->row('Automatic checks', 'Every ' . $scheduler->checkPeriod . ' hours'); + } else { + $this->row('Automatic checks', 'Disabled'); + } + if (isset($scheduler->throttleRedundantChecks)) { + if ($scheduler->throttleRedundantChecks && $scheduler->checkPeriod > 0) { + $this->row('Throttling', \sprintf('Enabled. If an update is already available, check for updates every %1$d hours instead of every %2$d hours.', $scheduler->throttledCheckPeriod, $scheduler->checkPeriod)); + } else { + $this->row('Throttling', 'Disabled'); + } + } + $this->updateChecker->onDisplayConfiguration($this); + echo '
'; + } + protected function displayConfigHeader() + { + //Do nothing. This should be implemented in subclasses. + } + protected function getMetadataButton() + { + return ''; + } + private function displayStatus() + { + echo '

Status

'; + echo ''; + $state = $this->updateChecker->getUpdateState(); + $checkNowButton = ''; + if (\function_exists('WCPOS\\Vendor\\get_submit_button')) { + $checkNowButton = get_submit_button('Check Now', 'secondary', 'puc-check-now-button', \false, array('id' => $this->updateChecker->getUniqueName('check-now-button'))); + } + if ($state->getLastCheck() > 0) { + $this->row('Last check', $this->formatTimeWithDelta($state->getLastCheck()) . ' ' . $checkNowButton . $this->responseBox); + } else { + $this->row('Last check', 'Never'); + } + $nextCheck = wp_next_scheduled($this->updateChecker->scheduler->getCronHookName()); + $this->row('Next automatic check', $this->formatTimeWithDelta($nextCheck)); + if ($state->getCheckedVersion() !== '') { + $this->row('Checked version', \htmlentities($state->getCheckedVersion())); + $this->row('Cached update', $state->getUpdate()); + } + $this->row('Update checker class', \htmlentities(\get_class($this->updateChecker))); + echo '
'; + } + private function displayCurrentUpdate() + { + $update = $this->updateChecker->getUpdate(); + if ($update !== null) { + echo '

An Update Is Available

'; + echo ''; + $fields = $this->getUpdateFields(); + foreach ($fields as $field) { + if (\property_exists($update, $field)) { + $this->row(\ucwords(\str_replace('_', ' ', $field)), isset($update->{$field}) ? \htmlentities($update->{$field}) : null); + } + } + echo '
'; + } else { + echo '

No updates currently available

'; + } + } + protected function getUpdateFields() + { + return array('version', 'download_url', 'slug'); + } + private function formatTimeWithDelta($unixTime) + { + if (empty($unixTime)) { + return 'Never'; + } + $delta = \time() - $unixTime; + $result = human_time_diff(\time(), $unixTime); + if ($delta < 0) { + $result = 'after ' . $result; + } else { + $result = $result . ' ago'; + } + $result .= ' (' . $this->formatTimestamp($unixTime) . ')'; + return $result; + } + private function formatTimestamp($unixTime) + { + return \gmdate('Y-m-d H:i:s', $unixTime + get_option('gmt_offset') * 3600); + } + public function row($name, $value) + { + if (\is_object($value) || \is_array($value)) { + //This is specifically for debugging, so print_r() is fine. + //phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r + $value = '
' . \htmlentities(\print_r($value, \true)) . '
'; + } else { + if ($value === null) { + $value = 'null'; + } + } + \printf( + '%1$s %2$s', + esc_html($name), + //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped above. + $value + ); + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/DebugBar/PluginExtension.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/DebugBar/PluginExtension.php new file mode 100644 index 0000000..45964d3 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/DebugBar/PluginExtension.php @@ -0,0 +1,37 @@ +updateChecker->getUniqueName('uid')) { + return; + } + $this->preAjaxRequest(); + $info = $this->updateChecker->requestInfo(); + if ($info !== null) { + echo 'Successfully retrieved plugin info from the metadata URL:'; + //phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r -- For debugging output. + echo '
', esc_html(\print_r($info, \true)), '
'; + } else { + echo 'Failed to retrieve plugin info from the metadata URL.'; + } + exit; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/DebugBar/PluginPanel.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/DebugBar/PluginPanel.php new file mode 100644 index 0000000..b9e8112 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/DebugBar/PluginPanel.php @@ -0,0 +1,31 @@ +row('Plugin file', \htmlentities($this->updateChecker->pluginFile)); + parent::displayConfigHeader(); + } + protected function getMetadataButton() + { + $requestInfoButton = ''; + if (\function_exists('WCPOS\\Vendor\\get_submit_button')) { + $requestInfoButton = get_submit_button('Request Info', 'secondary', 'puc-request-info-button', \false, array('id' => $this->updateChecker->getUniqueName('request-info-button'))); + } + return $requestInfoButton; + } + protected function getUpdateFields() + { + return \array_merge(parent::getUpdateFields(), array('homepage', 'upgrade_notice', 'tested')); + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/DebugBar/ThemePanel.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/DebugBar/ThemePanel.php new file mode 100644 index 0000000..99b3f96 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/DebugBar/ThemePanel.php @@ -0,0 +1,23 @@ +row('Theme directory', \htmlentities($this->updateChecker->directoryName)); + parent::displayConfigHeader(); + } + protected function getUpdateFields() + { + return \array_merge(parent::getUpdateFields(), array('details_url')); + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/InstalledPackage.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/InstalledPackage.php new file mode 100644 index 0000000..81d9430 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/InstalledPackage.php @@ -0,0 +1,92 @@ +updateChecker = $updateChecker; + } + /** + * Get the currently installed version of the plugin or theme. + * + * @return string|null Version number. + */ + public abstract function getInstalledVersion(); + /** + * Get the full path of the plugin or theme directory (without a trailing slash). + * + * @return string + */ + public abstract function getAbsoluteDirectoryPath(); + /** + * Check whether a regular file exists in the package's directory. + * + * @param string $relativeFileName File name relative to the package directory. + * @return bool + */ + public function fileExists($relativeFileName) + { + return \is_file($this->getAbsoluteDirectoryPath() . \DIRECTORY_SEPARATOR . \ltrim($relativeFileName, '/\\')); + } + /* ------------------------------------------------------------------- + * File header parsing + * ------------------------------------------------------------------- + */ + /** + * Parse plugin or theme metadata from the header comment. + * + * This is basically a simplified version of the get_file_data() function from /wp-includes/functions.php. + * It's intended as a utility for subclasses that detect updates by parsing files in a VCS. + * + * @param string|null $content File contents. + * @return string[] + */ + public function getFileHeader($content) + { + $content = (string) $content; + //WordPress only looks at the first 8 KiB of the file, so we do the same. + $content = \substr($content, 0, 8192); + //Normalize line endings. + $content = \str_replace("\r", "\n", $content); + $headers = $this->getHeaderNames(); + $results = array(); + foreach ($headers as $field => $name) { + $success = \preg_match('/^[ \\t\\/*#@]*' . \preg_quote($name, '/') . ':(.*)$/mi', $content, $matches); + if ($success === 1 && $matches[1]) { + $value = $matches[1]; + if (\function_exists('WCPOS\\Vendor\\_cleanup_header_comment')) { + $value = _cleanup_header_comment($value); + } + $results[$field] = $value; + } else { + $results[$field] = ''; + } + } + return $results; + } + /** + * @return array Format: ['HeaderKey' => 'Header Name'] + */ + protected abstract function getHeaderNames(); + /** + * Get the value of a specific plugin or theme header. + * + * @param string $headerName + * @return string Either the value of the header, or an empty string if the header doesn't exist. + */ + public abstract function getHeaderValue($headerName); + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Metadata.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Metadata.php new file mode 100644 index 0000000..b01f349 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Metadata.php @@ -0,0 +1,155 @@ + + */ + protected $extraProperties = array(); + /** + * Create an instance of this class from a JSON document. + * + * @abstract + * @param string $json + * @return self + */ + public static function fromJson($json) + { + throw new \LogicException('The ' . __METHOD__ . ' method must be implemented by subclasses'); + } + /** + * @param string $json + * @param self $target + * @return bool + */ + protected static function createFromJson($json, $target) + { + /** @var \StdClass $apiResponse */ + $apiResponse = \json_decode($json); + if (empty($apiResponse) || !\is_object($apiResponse)) { + $errorMessage = "Failed to parse update metadata. Try validating your .json file with https://jsonlint.com/"; + do_action('puc_api_error', new \WCPOS\Vendor\WP_Error('puc-invalid-json', $errorMessage)); + //phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error -- For plugin developers. + \trigger_error(esc_html($errorMessage), \E_USER_NOTICE); + return \false; + } + $valid = $target->validateMetadata($apiResponse); + if (is_wp_error($valid)) { + do_action('puc_api_error', $valid); + //phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error -- For plugin developers. + \trigger_error(esc_html($valid->get_error_message()), \E_USER_NOTICE); + return \false; + } + foreach (\get_object_vars($apiResponse) as $key => $value) { + $target->{$key} = $value; + } + return \true; + } + /** + * No validation by default! Subclasses should check that the required fields are present. + * + * @param \StdClass $apiResponse + * @return bool|\WP_Error + */ + protected function validateMetadata($apiResponse) + { + return \true; + } + /** + * Create a new instance by copying the necessary fields from another object. + * + * @abstract + * @param \StdClass|self $object The source object. + * @return self The new copy. + */ + public static function fromObject($object) + { + throw new \LogicException('The ' . __METHOD__ . ' method must be implemented by subclasses'); + } + /** + * Create an instance of StdClass that can later be converted back to an + * update or info container. Useful for serialization and caching, as it + * avoids the "incomplete object" problem if the cached value is loaded + * before this class. + * + * @return \StdClass + */ + public function toStdClass() + { + $object = new \stdClass(); + $this->copyFields($this, $object); + return $object; + } + /** + * Transform the metadata into the format used by WordPress core. + * + * @return object + */ + public abstract function toWpFormat(); + /** + * Copy known fields from one object to another. + * + * @param \StdClass|self $from + * @param \StdClass|self $to + */ + protected function copyFields($from, $to) + { + $fields = $this->getFieldNames(); + if (\property_exists($from, 'slug') && !empty($from->slug)) { + //Let plugins add extra fields without having to create subclasses. + $fields = apply_filters($this->getPrefixedFilter('retain_fields') . '-' . $from->slug, $fields); + } + foreach ($fields as $field) { + if (\property_exists($from, $field)) { + $to->{$field} = $from->{$field}; + } + } + } + /** + * @return string[] + */ + protected function getFieldNames() + { + return array(); + } + /** + * @param string $tag + * @return string + */ + protected function getPrefixedFilter($tag) + { + return 'puc_' . $tag; + } + public function __set($name, $value) + { + $this->extraProperties[$name] = $value; + } + public function __get($name) + { + return isset($this->extraProperties[$name]) ? $this->extraProperties[$name] : null; + } + public function __isset($name) + { + return isset($this->extraProperties[$name]); + } + public function __unset($name) + { + unset($this->extraProperties[$name]); + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/OAuthSignature.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/OAuthSignature.php new file mode 100644 index 0000000..65c6479 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/OAuthSignature.php @@ -0,0 +1,83 @@ +consumerKey = $consumerKey; + $this->consumerSecret = $consumerSecret; + } + /** + * Sign a URL using OAuth 1.0. + * + * @param string $url The URL to be signed. It may contain query parameters. + * @param string $method HTTP method such as "GET", "POST" and so on. + * @return string The signed URL. + */ + public function sign($url, $method = 'GET') + { + $parameters = array(); + //Parse query parameters. + $query = wp_parse_url($url, \PHP_URL_QUERY); + if (!empty($query)) { + \parse_str($query, $parsedParams); + if (\is_array($parsedParams)) { + $parameters = $parsedParams; + } + //Remove the query string from the URL. We'll replace it later. + $url = \substr($url, 0, \strpos($url, '?')); + } + $parameters = \array_merge($parameters, array('oauth_consumer_key' => $this->consumerKey, 'oauth_nonce' => $this->nonce(), 'oauth_signature_method' => 'HMAC-SHA1', 'oauth_timestamp' => \time(), 'oauth_version' => '1.0')); + unset($parameters['oauth_signature']); + //Parameters must be sorted alphabetically before signing. + \ksort($parameters); + //The most complicated part of the request - generating the signature. + //The string to sign contains the HTTP method, the URL path, and all of + //our query parameters. Everything is URL encoded. Then we concatenate + //them with ampersands into a single string to hash. + $encodedVerb = \urlencode($method); + $encodedUrl = \urlencode($url); + $encodedParams = \urlencode(\http_build_query($parameters, '', '&')); + $stringToSign = $encodedVerb . '&' . $encodedUrl . '&' . $encodedParams; + //Since we only have one OAuth token (the consumer secret) we only have + //to use it as our HMAC key. However, we still have to append an & to it + //as if we were using it with additional tokens. + $secret = \urlencode($this->consumerSecret) . '&'; + //The signature is a hash of the consumer key and the base string. Note + //that we have to get the raw output from hash_hmac and base64 encode + //the binary data result. + $parameters['oauth_signature'] = \base64_encode(\hash_hmac('sha1', $stringToSign, $secret, \true)); + return $url . '?' . \http_build_query($parameters); + } + /** + * Generate a random nonce. + * + * @return string + */ + private function nonce() + { + $mt = \microtime(); + $rand = null; + if (\is_callable('random_bytes')) { + try { + $rand = \random_bytes(16); + } catch (\Exception $ex) { + //Fall back to mt_rand (below). + } + } + if ($rand === null) { + //phpcs:ignore WordPress.WP.AlternativeFunctions.rand_mt_rand + $rand = \function_exists('WCPOS\\Vendor\\wp_rand') ? wp_rand() : \mt_rand(); + } + return \md5($mt . '_' . $rand); + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Plugin/Package.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Plugin/Package.php new file mode 100644 index 0000000..0b36f13 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Plugin/Package.php @@ -0,0 +1,163 @@ +pluginAbsolutePath = $pluginAbsolutePath; + $this->pluginFile = plugin_basename($this->pluginAbsolutePath); + parent::__construct($updateChecker); + //Clear the version number cache when something - anything - is upgraded or WP clears the update cache. + add_filter('upgrader_post_install', array($this, 'clearCachedVersion')); + add_action('delete_site_transient_update_plugins', array($this, 'clearCachedVersion')); + } + public function getInstalledVersion() + { + if (isset($this->cachedInstalledVersion)) { + return $this->cachedInstalledVersion; + } + $pluginHeader = $this->getPluginHeader(); + if (isset($pluginHeader['Version'])) { + $this->cachedInstalledVersion = $pluginHeader['Version']; + return $pluginHeader['Version']; + } else { + //This can happen if the filename points to something that is not a plugin. + $this->updateChecker->triggerError(\sprintf("Cannot read the Version header for '%s'. The filename is incorrect or is not a plugin.", $this->updateChecker->pluginFile), \E_USER_WARNING); + return null; + } + } + /** + * Clear the cached plugin version. This method can be set up as a filter (hook) and will + * return the filter argument unmodified. + * + * @param mixed $filterArgument + * @return mixed + */ + public function clearCachedVersion($filterArgument = null) + { + $this->cachedInstalledVersion = null; + return $filterArgument; + } + public function getAbsoluteDirectoryPath() + { + return \dirname($this->pluginAbsolutePath); + } + /** + * Get the value of a specific plugin or theme header. + * + * @param string $headerName + * @param string $defaultValue + * @return string Either the value of the header, or $defaultValue if the header doesn't exist or is empty. + */ + public function getHeaderValue($headerName, $defaultValue = '') + { + $headers = $this->getPluginHeader(); + if (isset($headers[$headerName]) && $headers[$headerName] !== '') { + return $headers[$headerName]; + } + return $defaultValue; + } + protected function getHeaderNames() + { + return array( + 'Name' => 'Plugin Name', + 'PluginURI' => 'Plugin URI', + 'Version' => 'Version', + 'Description' => 'Description', + 'Author' => 'Author', + 'AuthorURI' => 'Author URI', + 'TextDomain' => 'Text Domain', + 'DomainPath' => 'Domain Path', + 'Network' => 'Network', + //The newest WordPress version that this plugin requires or has been tested with. + //We support several different formats for compatibility with other libraries. + 'Tested WP' => 'Tested WP', + 'Requires WP' => 'Requires WP', + 'Tested up to' => 'Tested up to', + 'Requires at least' => 'Requires at least', + ); + } + /** + * Get the translated plugin title. + * + * @return string + */ + public function getPluginTitle() + { + $title = ''; + $header = $this->getPluginHeader(); + if ($header && !empty($header['Name']) && isset($header['TextDomain'])) { + $title = translate($header['Name'], $header['TextDomain']); + } + return $title; + } + /** + * Get plugin's metadata from its file header. + * + * @return array + */ + public function getPluginHeader() + { + if (!\is_file($this->pluginAbsolutePath)) { + //This can happen if the plugin filename is wrong. + $this->updateChecker->triggerError(\sprintf("Can't to read the plugin header for '%s'. The file does not exist.", $this->updateChecker->pluginFile), \E_USER_WARNING); + return array(); + } + if (!\function_exists('WCPOS\\Vendor\\get_plugin_data')) { + require_once ABSPATH . '/wp-admin/includes/plugin.php'; + } + return get_plugin_data($this->pluginAbsolutePath, \false, \false); + } + public function removeHooks() + { + remove_filter('upgrader_post_install', array($this, 'clearCachedVersion')); + remove_action('delete_site_transient_update_plugins', array($this, 'clearCachedVersion')); + } + /** + * Check if the plugin file is inside the mu-plugins directory. + * + * @return bool + */ + public function isMuPlugin() + { + static $cachedResult = null; + if ($cachedResult === null) { + if (!\defined('WPMU_PLUGIN_DIR') || !\is_string(WPMU_PLUGIN_DIR)) { + $cachedResult = \false; + return $cachedResult; + } + //Convert both paths to the canonical form before comparison. + $muPluginDir = \realpath(WPMU_PLUGIN_DIR); + $pluginPath = \realpath($this->pluginAbsolutePath); + //If realpath() fails, just normalize the syntax instead. + if ($muPluginDir === \false || $pluginPath === \false) { + $muPluginDir = \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\PucFactory::normalizePath(WPMU_PLUGIN_DIR); + $pluginPath = \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\PucFactory::normalizePath($this->pluginAbsolutePath); + } + $cachedResult = \strpos($pluginPath, $muPluginDir) === 0; + } + return $cachedResult; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Plugin/PluginInfo.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Plugin/PluginInfo.php new file mode 100644 index 0000000..d80e3fd --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Plugin/PluginInfo.php @@ -0,0 +1,112 @@ +sections = (array) $instance->sections; + $instance->icons = (array) $instance->icons; + return $instance; + } + /** + * Very, very basic validation. + * + * @param \StdClass $apiResponse + * @return bool|\WP_Error + */ + protected function validateMetadata($apiResponse) + { + if (!isset($apiResponse->name, $apiResponse->version) || empty($apiResponse->name) || empty($apiResponse->version)) { + return new \WCPOS\Vendor\WP_Error('puc-invalid-metadata', "The plugin metadata file does not contain the required 'name' and/or 'version' keys."); + } + return \true; + } + /** + * Transform plugin info into the format used by the native WordPress.org API + * + * @return object + */ + public function toWpFormat() + { + $info = new \stdClass(); + //The custom update API is built so that many fields have the same name and format + //as those returned by the native WordPress.org API. These can be assigned directly. + $sameFormat = array('name', 'slug', 'version', 'requires', 'tested', 'rating', 'upgrade_notice', 'num_ratings', 'downloaded', 'active_installs', 'homepage', 'last_updated', 'requires_php'); + foreach ($sameFormat as $field) { + if (isset($this->{$field})) { + $info->{$field} = $this->{$field}; + } else { + $info->{$field} = null; + } + } + //Other fields need to be renamed and/or transformed. + $info->download_link = $this->download_url; + $info->author = $this->getFormattedAuthor(); + $info->sections = \array_merge(array('description' => ''), $this->sections); + if (!empty($this->banners)) { + //WP expects an array with two keys: "high" and "low". Both are optional. + //Docs: https://wordpress.org/plugins/about/faq/#banners + $info->banners = \is_object($this->banners) ? \get_object_vars($this->banners) : $this->banners; + $info->banners = \array_intersect_key($info->banners, array('high' => \true, 'low' => \true)); + } + return $info; + } + protected function getFormattedAuthor() + { + if (!empty($this->author_homepage)) { + /** @noinspection HtmlUnknownTarget */ + return \sprintf('%s', $this->author_homepage, $this->author); + } + return $this->author; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Plugin/Ui.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Plugin/Ui.php new file mode 100644 index 0000000..d2430e5 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Plugin/Ui.php @@ -0,0 +1,240 @@ +updateChecker = $updateChecker; + $this->manualCheckErrorTransient = $this->updateChecker->getUniqueName('manual_check_errors'); + add_action('admin_init', array($this, 'onAdminInit')); + } + public function onAdminInit() + { + if ($this->updateChecker->userCanInstallUpdates()) { + $this->handleManualCheck(); + add_filter('plugin_row_meta', array($this, 'addViewDetailsLink'), 10, 3); + add_filter('plugin_row_meta', array($this, 'addCheckForUpdatesLink'), 10, 2); + add_action('all_admin_notices', array($this, 'displayManualCheckResult')); + } + } + /** + * Add a "View Details" link to the plugin row in the "Plugins" page. By default, + * the new link will appear before the "Visit plugin site" link (if present). + * + * You can change the link text by using the "puc_view_details_link-$slug" filter. + * Returning an empty string from the filter will disable the link. + * + * You can change the position of the link using the + * "puc_view_details_link_position-$slug" filter. + * Returning 'before' or 'after' will place the link immediately before/after + * the "Visit plugin site" link. + * Returning 'append' places the link after any existing links at the time of the hook. + * Returning 'replace' replaces the "Visit plugin site" link. + * Returning anything else disables the link when there is a "Visit plugin site" link. + * + * If there is no "Visit plugin site" link 'append' is always used! + * + * @param array $pluginMeta Array of meta links. + * @param string $pluginFile + * @param array $pluginData Array of plugin header data. + * @return array + */ + public function addViewDetailsLink($pluginMeta, $pluginFile, $pluginData = array()) + { + if ($this->isMyPluginFile($pluginFile) && !isset($pluginData['slug'])) { + $linkText = apply_filters($this->updateChecker->getUniqueName('view_details_link'), __('View details')); + if (!empty($linkText)) { + $viewDetailsLinkPosition = 'append'; + //Find the "Visit plugin site" link (if present). + $visitPluginSiteLinkIndex = \count($pluginMeta) - 1; + if ($pluginData['PluginURI']) { + $escapedPluginUri = esc_url($pluginData['PluginURI']); + foreach ($pluginMeta as $linkIndex => $existingLink) { + if (\strpos($existingLink, $escapedPluginUri) !== \false) { + $visitPluginSiteLinkIndex = $linkIndex; + $viewDetailsLinkPosition = apply_filters($this->updateChecker->getUniqueName('view_details_link_position'), 'before'); + break; + } + } + } + $viewDetailsLink = \sprintf('%s', esc_url(network_admin_url('plugin-install.php?tab=plugin-information&plugin=' . \urlencode($this->updateChecker->slug) . '&TB_iframe=true&width=600&height=550')), esc_attr(\sprintf(__('More information about %s'), $pluginData['Name'])), esc_attr($pluginData['Name']), $linkText); + switch ($viewDetailsLinkPosition) { + case 'before': + \array_splice($pluginMeta, $visitPluginSiteLinkIndex, 0, $viewDetailsLink); + break; + case 'after': + \array_splice($pluginMeta, $visitPluginSiteLinkIndex + 1, 0, $viewDetailsLink); + break; + case 'replace': + $pluginMeta[$visitPluginSiteLinkIndex] = $viewDetailsLink; + break; + case 'append': + default: + $pluginMeta[] = $viewDetailsLink; + break; + } + } + } + return $pluginMeta; + } + /** + * Add a "Check for updates" link to the plugin row in the "Plugins" page. By default, + * the new link will appear after the "Visit plugin site" link if present, otherwise + * after the "View plugin details" link. + * + * You can change the link text by using the "puc_manual_check_link-$slug" filter. + * Returning an empty string from the filter will disable the link. + * + * @param array $pluginMeta Array of meta links. + * @param string $pluginFile + * @return array + */ + public function addCheckForUpdatesLink($pluginMeta, $pluginFile) + { + if ($this->isMyPluginFile($pluginFile)) { + $linkUrl = wp_nonce_url(add_query_arg(array('puc_check_for_updates' => 1, 'puc_slug' => $this->updateChecker->slug), self_admin_url('plugins.php')), 'puc_check_for_updates'); + $linkText = apply_filters($this->updateChecker->getUniqueName('manual_check_link'), __('Check for updates', 'plugin-update-checker')); + if (!empty($linkText)) { + /** @noinspection HtmlUnknownTarget */ + $pluginMeta[] = \sprintf('%s', esc_attr($linkUrl), $linkText); + } + } + return $pluginMeta; + } + protected function isMyPluginFile($pluginFile) + { + return $pluginFile == $this->updateChecker->pluginFile || !empty($this->updateChecker->muPluginFile) && $pluginFile == $this->updateChecker->muPluginFile; + } + /** + * Check for updates when the user clicks the "Check for updates" link. + * + * @see self::addCheckForUpdatesLink() + * + * @return void + */ + public function handleManualCheck() + { + $shouldCheck = isset($_GET['puc_check_for_updates'], $_GET['puc_slug']) && $_GET['puc_slug'] == $this->updateChecker->slug && check_admin_referer('puc_check_for_updates'); + if ($shouldCheck) { + $update = $this->updateChecker->checkForUpdates(); + $status = $update === null ? 'no_update' : 'update_available'; + $lastRequestApiErrors = $this->updateChecker->getLastRequestApiErrors(); + if ($update === null && !empty($lastRequestApiErrors)) { + //Some errors are not critical. For example, if PUC tries to retrieve the readme.txt + //file from GitHub and gets a 404, that's an API error, but it doesn't prevent updates + //from working. Maybe the plugin simply doesn't have a readme. + //Let's only show important errors. + $foundCriticalErrors = \false; + $questionableErrorCodes = array('puc-github-http-error', 'puc-gitlab-http-error', 'puc-bitbucket-http-error'); + foreach ($lastRequestApiErrors as $item) { + $wpError = $item['error']; + /** @var \WP_Error $wpError */ + if (!\in_array($wpError->get_error_code(), $questionableErrorCodes)) { + $foundCriticalErrors = \true; + break; + } + } + if ($foundCriticalErrors) { + $status = 'error'; + set_site_transient($this->manualCheckErrorTransient, $lastRequestApiErrors, 60); + } + } + wp_redirect(add_query_arg(array('puc_update_check_result' => $status, 'puc_slug' => $this->updateChecker->slug), self_admin_url('plugins.php'))); + exit; + } + } + /** + * Display the results of a manual update check. + * + * @see self::handleManualCheck() + * + * You can change the result message by using the "puc_manual_check_message-$slug" filter. + */ + public function displayManualCheckResult() + { + //phpcs:disable WordPress.Security.NonceVerification.Recommended -- Just displaying a message. + if (isset($_GET['puc_update_check_result'], $_GET['puc_slug']) && $_GET['puc_slug'] == $this->updateChecker->slug) { + $status = sanitize_key($_GET['puc_update_check_result']); + $title = $this->updateChecker->getInstalledPackage()->getPluginTitle(); + $noticeClass = 'updated notice-success'; + $details = ''; + if ($status == 'no_update') { + $message = \sprintf(_x('The %s plugin is up to date.', 'the plugin title', 'plugin-update-checker'), $title); + } else { + if ($status == 'update_available') { + $message = \sprintf(_x('A new version of the %s plugin is available.', 'the plugin title', 'plugin-update-checker'), $title); + } else { + if ($status === 'error') { + $message = \sprintf(_x('Could not determine if updates are available for %s.', 'the plugin title', 'plugin-update-checker'), $title); + $noticeClass = 'error notice-error'; + $details = $this->formatManualCheckErrors(get_site_transient($this->manualCheckErrorTransient)); + delete_site_transient($this->manualCheckErrorTransient); + } else { + $message = \sprintf(__('Unknown update checker status "%s"', 'plugin-update-checker'), $status); + $noticeClass = 'error notice-error'; + } + } + } + $message = esc_html($message); + //Plugins can replace the message with their own, including adding HTML. + $message = apply_filters($this->updateChecker->getUniqueName('manual_check_message'), $message, $status); + \printf( + '

%s

%s
', + esc_attr($noticeClass), + //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Was escaped above, and plugins can add HTML. + $message, + //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Contains HTML. Content should already be escaped. + $details + ); + } + //phpcs:enable + } + /** + * Format the list of errors that were thrown during an update check. + * + * @param array $errors + * @return string + */ + protected function formatManualCheckErrors($errors) + { + if (empty($errors)) { + return ''; + } + $output = ''; + $showAsList = \count($errors) > 1; + if ($showAsList) { + $output .= '
    '; + $formatString = '
  1. %1$s %2$s
  2. '; + } else { + $formatString = '

    %1$s %2$s

    '; + } + foreach ($errors as $item) { + $wpError = $item['error']; + /** @var \WP_Error $wpError */ + $output .= \sprintf($formatString, esc_html($wpError->get_error_message()), esc_html($wpError->get_error_code())); + } + if ($showAsList) { + $output .= '
'; + } + return $output; + } + public function removeHooks() + { + remove_action('admin_init', array($this, 'onAdminInit')); + remove_filter('plugin_row_meta', array($this, 'addViewDetailsLink'), 10); + remove_filter('plugin_row_meta', array($this, 'addCheckForUpdatesLink'), 10); + remove_action('all_admin_notices', array($this, 'displayManualCheckResult')); + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Plugin/Update.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Plugin/Update.php new file mode 100644 index 0000000..8b0af48 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Plugin/Update.php @@ -0,0 +1,105 @@ +copyFields($object, $update); + return $update; + } + /** + * @return string[] + */ + protected function getFieldNames() + { + return \array_merge(parent::getFieldNames(), self::$extraFields); + } + /** + * Transform the update into the format used by WordPress native plugin API. + * + * @return object + */ + public function toWpFormat() + { + $update = parent::toWpFormat(); + $update->id = $this->id; + $update->url = $this->homepage; + $update->tested = $this->tested; + $update->requires_php = $this->requires_php; + $update->plugin = $this->filename; + if (!empty($this->upgrade_notice)) { + $update->upgrade_notice = $this->upgrade_notice; + } + if (!empty($this->icons) && \is_array($this->icons)) { + //This should be an array with up to 4 keys: 'svg', '1x', '2x' and 'default'. + //Docs: https://developer.wordpress.org/plugins/wordpress-org/plugin-assets/#plugin-icons + $icons = \array_intersect_key($this->icons, array('svg' => \true, '1x' => \true, '2x' => \true, 'default' => \true)); + if (!empty($icons)) { + $update->icons = $icons; + //It appears that the 'default' icon isn't used anywhere in WordPress 4.9, + //but lets set it just in case a future release needs it. + if (!isset($update->icons['default'])) { + $update->icons['default'] = \current($update->icons); + } + } + } + return $update; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Plugin/UpdateChecker.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Plugin/UpdateChecker.php new file mode 100644 index 0000000..59cf6d7 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Plugin/UpdateChecker.php @@ -0,0 +1,387 @@ +pluginAbsolutePath = $pluginFile; + $this->pluginFile = plugin_basename($this->pluginAbsolutePath); + $this->muPluginFile = $muPluginFile; + //If no slug is specified, use the name of the main plugin file as the slug. + //For example, 'my-cool-plugin/cool-plugin.php' becomes 'cool-plugin'. + if (empty($slug)) { + $slug = \basename($this->pluginFile, '.php'); + } + //Plugin slugs must be unique. + $slugCheckFilter = 'puc_is_slug_in_use-' . $slug; + $slugUsedBy = apply_filters($slugCheckFilter, \false); + if ($slugUsedBy) { + $this->triggerError(\sprintf('Plugin slug "%s" is already in use by %s. Slugs must be unique.', $slug, $slugUsedBy), \E_USER_ERROR); + } + add_filter($slugCheckFilter, array($this, 'getAbsolutePath')); + parent::__construct($metadataUrl, \dirname($this->pluginFile), $slug, $checkPeriod, $optionName); + //Backwards compatibility: If the plugin is a mu-plugin but no $muPluginFile is specified, assume + //it's the same as $pluginFile given that it's not in a subdirectory (WP only looks in the base dir). + if (\strpbrk($this->pluginFile, '/\\') === \false && $this->isUnknownMuPlugin()) { + $this->muPluginFile = $this->pluginFile; + } + //To prevent a crash during plugin uninstallation, remove updater hooks when the user removes the plugin. + //Details: https://github.com/YahnisElsts/plugin-update-checker/issues/138#issuecomment-335590964 + add_action('uninstall_' . $this->pluginFile, array($this, 'removeHooks')); + $this->extraUi = new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Plugin\Ui($this); + } + /** + * Create an instance of the scheduler. + * + * @param int $checkPeriod + * @return Scheduler + */ + protected function createScheduler($checkPeriod) + { + $scheduler = new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Scheduler($this, $checkPeriod, array('load-plugins.php')); + register_deactivation_hook($this->pluginFile, array($scheduler, 'removeUpdaterCron')); + return $scheduler; + } + /** + * Install the hooks required to run periodic update checks and inject update info + * into WP data structures. + * + * @return void + */ + protected function installHooks() + { + //Override requests for plugin information + add_filter('plugins_api', array($this, 'injectInfo'), 20, 3); + parent::installHooks(); + } + /** + * Remove update checker hooks. + * + * The intent is to prevent a fatal error that can happen if the plugin has an uninstall + * hook. During uninstallation, WP includes the main plugin file (which creates a PUC instance), + * the uninstall hook runs, WP deletes the plugin files and then updates some transients. + * If PUC hooks are still around at this time, they could throw an error while trying to + * autoload classes from files that no longer exist. + * + * The "site_transient_{$transient}" filter is the main problem here, but let's also remove + * most other PUC hooks to be safe. + * + * @internal + */ + public function removeHooks() + { + parent::removeHooks(); + $this->extraUi->removeHooks(); + $this->package->removeHooks(); + remove_filter('plugins_api', array($this, 'injectInfo'), 20); + } + /** + * Retrieve plugin info from the configured API endpoint. + * + * @uses wp_remote_get() + * + * @param array $queryArgs Additional query arguments to append to the request. Optional. + * @return PluginInfo + */ + public function requestInfo($queryArgs = array()) + { + list($pluginInfo, $result) = $this->requestMetadata(\WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Plugin\PluginInfo::class, 'request_info', $queryArgs); + if ($pluginInfo !== null) { + /** @var PluginInfo $pluginInfo */ + $pluginInfo->filename = $this->pluginFile; + $pluginInfo->slug = $this->slug; + } + $pluginInfo = apply_filters($this->getUniqueName('request_info_result'), $pluginInfo, $result); + return $pluginInfo; + } + /** + * Retrieve the latest update (if any) from the configured API endpoint. + * + * @uses UpdateChecker::requestInfo() + * + * @return Update|null An instance of Plugin Update, or NULL when no updates are available. + */ + public function requestUpdate() + { + //For the sake of simplicity, this function just calls requestInfo() + //and transforms the result accordingly. + $pluginInfo = $this->requestInfo(array('checking_for_updates' => '1')); + if ($pluginInfo === null) { + return null; + } + $update = \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Plugin\Update::fromPluginInfo($pluginInfo); + $update = $this->filterUpdateResult($update); + return $update; + } + /** + * Intercept plugins_api() calls that request information about our plugin and + * use the configured API endpoint to satisfy them. + * + * @see plugins_api() + * + * @param mixed $result + * @param string $action + * @param array|object $args + * @return mixed + */ + public function injectInfo($result, $action = null, $args = null) + { + $relevant = $action == 'plugin_information' && isset($args->slug) && ($args->slug == $this->slug || $args->slug == \dirname($this->pluginFile)); + if (!$relevant) { + return $result; + } + $pluginInfo = $this->requestInfo(); + $this->fixSupportedWordpressVersion($pluginInfo); + $pluginInfo = apply_filters($this->getUniqueName('pre_inject_info'), $pluginInfo); + if ($pluginInfo) { + return $pluginInfo->toWpFormat(); + } + return $result; + } + protected function shouldShowUpdates() + { + //No update notifications for mu-plugins unless explicitly enabled. The MU plugin file + //is usually different from the main plugin file so the update wouldn't show up properly anyway. + return !$this->isUnknownMuPlugin(); + } + /** + * @param \stdClass|null $updates + * @param \stdClass $updateToAdd + * @return \stdClass + */ + protected function addUpdateToList($updates, $updateToAdd) + { + if ($this->package->isMuPlugin()) { + //WP does not support automatic update installation for mu-plugins, but we can + //still display a notice. + $updateToAdd->package = null; + } + return parent::addUpdateToList($updates, $updateToAdd); + } + /** + * @param \stdClass|null $updates + * @return \stdClass|null + */ + protected function removeUpdateFromList($updates) + { + $updates = parent::removeUpdateFromList($updates); + if (!empty($this->muPluginFile) && isset($updates, $updates->response)) { + unset($updates->response[$this->muPluginFile]); + } + return $updates; + } + /** + * For plugins, the update array is indexed by the plugin filename relative to the "plugins" + * directory. Example: "plugin-name/plugin.php". + * + * @return string + */ + protected function getUpdateListKey() + { + if ($this->package->isMuPlugin()) { + return $this->muPluginFile; + } + return $this->pluginFile; + } + protected function getNoUpdateItemFields() + { + return \array_merge(parent::getNoUpdateItemFields(), array('id' => $this->pluginFile, 'slug' => $this->slug, 'plugin' => $this->pluginFile, 'icons' => array(), 'banners' => array(), 'banners_rtl' => array(), 'tested' => '', 'compatibility' => new \stdClass())); + } + /** + * Alias for isBeingUpgraded(). + * + * @deprecated + * @param \WP_Upgrader|null $upgrader The upgrader that's performing the current update. + * @return bool + */ + public function isPluginBeingUpgraded($upgrader = null) + { + return $this->isBeingUpgraded($upgrader); + } + /** + * Is there an update being installed for this plugin, right now? + * + * @param \WP_Upgrader|null $upgrader + * @return bool + */ + public function isBeingUpgraded($upgrader = null) + { + return $this->upgraderStatus->isPluginBeingUpgraded($this->pluginFile, $upgrader); + } + /** + * Get the details of the currently available update, if any. + * + * If no updates are available, or if the last known update version is below or equal + * to the currently installed version, this method will return NULL. + * + * Uses cached update data. To retrieve update information straight from + * the metadata URL, call requestUpdate() instead. + * + * @return Update|null + */ + public function getUpdate() + { + $update = parent::getUpdate(); + if (isset($update)) { + /** @var Update $update */ + $update->filename = $this->pluginFile; + } + return $update; + } + /** + * Get the translated plugin title. + * + * @deprecated + * @return string + */ + public function getPluginTitle() + { + return $this->package->getPluginTitle(); + } + /** + * Check if the current user has the required permissions to install updates. + * + * @return bool + */ + public function userCanInstallUpdates() + { + return current_user_can('update_plugins'); + } + /** + * Check if the plugin file is inside the mu-plugins directory. + * + * @deprecated + * @return bool + */ + protected function isMuPlugin() + { + return $this->package->isMuPlugin(); + } + /** + * MU plugins are partially supported, but only when we know which file in mu-plugins + * corresponds to this plugin. + * + * @return bool + */ + protected function isUnknownMuPlugin() + { + return empty($this->muPluginFile) && $this->package->isMuPlugin(); + } + /** + * Get absolute path to the main plugin file. + * + * @return string + */ + public function getAbsolutePath() + { + return $this->pluginAbsolutePath; + } + /** + * Register a callback for filtering query arguments. + * + * The callback function should take one argument - an associative array of query arguments. + * It should return a modified array of query arguments. + * + * @uses add_filter() This method is a convenience wrapper for add_filter(). + * + * @param callable $callback + * @return void + */ + public function addQueryArgFilter($callback) + { + $this->addFilter('request_info_query_args', $callback); + } + /** + * Register a callback for filtering arguments passed to wp_remote_get(). + * + * The callback function should take one argument - an associative array of arguments - + * and return a modified array or arguments. See the WP documentation on wp_remote_get() + * for details on what arguments are available and how they work. + * + * @uses add_filter() This method is a convenience wrapper for add_filter(). + * + * @param callable $callback + * @return void + */ + public function addHttpRequestArgFilter($callback) + { + $this->addFilter('request_info_options', $callback); + } + /** + * Register a callback for filtering the plugin info retrieved from the external API. + * + * The callback function should take two arguments. If the plugin info was retrieved + * successfully, the first argument passed will be an instance of PluginInfo. Otherwise, + * it will be NULL. The second argument will be the corresponding return value of + * wp_remote_get (see WP docs for details). + * + * The callback function should return a new or modified instance of PluginInfo or NULL. + * + * @uses add_filter() This method is a convenience wrapper for add_filter(). + * + * @param callable $callback + * @return void + */ + public function addResultFilter($callback) + { + $this->addFilter('request_info_result', $callback, 10, 2); + } + protected function createDebugBarExtension() + { + return new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\DebugBar\PluginExtension($this); + } + /** + * Create a package instance that represents this plugin or theme. + * + * @return InstalledPackage + */ + protected function createInstalledPackage() + { + return new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Plugin\Package($this->pluginAbsolutePath, $this); + } + /** + * @return Package + */ + public function getInstalledPackage() + { + return $this->package; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/PucFactory.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/PucFactory.php new file mode 100644 index 0000000..b5c51ff --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/PucFactory.php @@ -0,0 +1,297 @@ + '', 'slug' => '', 'checkPeriod' => 12, 'optionName' => '', 'muPluginFile' => ''); + $args = \array_merge($defaults, \array_intersect_key($args, $defaults)); + \extract($args, \EXTR_SKIP); + //Check for the service URI + if (empty($metadataUrl)) { + $metadataUrl = self::getServiceURI($fullPath); + } + return self::buildUpdateChecker($metadataUrl, $fullPath, $slug, $checkPeriod, $optionName, $muPluginFile); + } + /** + * Create a new instance of the update checker. + * + * This method automatically detects if you're using it for a plugin or a theme and chooses + * the appropriate implementation for your update source (JSON file, GitHub, BitBucket, etc). + * + * @see UpdateChecker::__construct + * + * @param string $metadataUrl The URL of the metadata file, a GitHub repository, or another supported update source. + * @param string $fullPath Full path to the main plugin file or to the theme directory. + * @param string $slug Custom slug. Defaults to the name of the main plugin file or the theme directory. + * @param int $checkPeriod How often to check for updates (in hours). + * @param string $optionName Where to store bookkeeping info about update checks. + * @param string $muPluginFile The plugin filename relative to the mu-plugins directory. + * @return Plugin\UpdateChecker|Theme\UpdateChecker|Vcs\BaseChecker + */ + public static function buildUpdateChecker($metadataUrl, $fullPath, $slug = '', $checkPeriod = 12, $optionName = '', $muPluginFile = '') + { + $fullPath = self::normalizePath($fullPath); + $id = null; + //Plugin or theme? + $themeDirectory = self::getThemeDirectoryName($fullPath); + if (self::isPluginFile($fullPath)) { + $type = 'Plugin'; + $id = $fullPath; + } else { + if ($themeDirectory !== null) { + $type = 'Theme'; + $id = $themeDirectory; + } else { + throw new \RuntimeException(\sprintf('The update checker cannot determine if "%s" is a plugin or a theme. ' . 'This is a bug. Please contact the PUC developer.', \htmlentities($fullPath))); + } + } + //Which hosting service does the URL point to? + $service = self::getVcsService($metadataUrl); + $apiClass = null; + if (empty($service)) { + //The default is to get update information from a remote JSON file. + $checkerClass = $type . '\\UpdateChecker'; + } else { + //You can also use a VCS repository like GitHub. + $checkerClass = 'Vcs\\' . $type . 'UpdateChecker'; + $apiClass = $service . 'Api'; + } + $checkerClass = self::getCompatibleClassVersion($checkerClass); + if ($checkerClass === null) { + //phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + \trigger_error(esc_html(\sprintf('PUC %s does not support updates for %ss %s', self::$latestCompatibleVersion, \strtolower($type), $service ? 'hosted on ' . $service : 'using JSON metadata')), \E_USER_ERROR); + } + if (!isset($apiClass)) { + //Plain old update checker. + return new $checkerClass($metadataUrl, $id, $slug, $checkPeriod, $optionName, $muPluginFile); + } else { + //VCS checker + an API client. + $apiClass = self::getCompatibleClassVersion($apiClass); + if ($apiClass === null) { + //phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + \trigger_error(esc_html(\sprintf('PUC %s does not support %s', self::$latestCompatibleVersion, $service)), \E_USER_ERROR); + } + return new $checkerClass(new $apiClass($metadataUrl), $id, $slug, $checkPeriod, $optionName, $muPluginFile); + } + } + /** + * + * Normalize a filesystem path. Introduced in WP 3.9. + * Copying here allows use of the class on earlier versions. + * This version adapted from WP 4.8.2 (unchanged since 4.5.2) + * + * @param string $path Path to normalize. + * @return string Normalized path. + */ + public static function normalizePath($path) + { + if (\function_exists('WCPOS\\Vendor\\wp_normalize_path')) { + return wp_normalize_path($path); + } + $path = \str_replace('\\', '/', $path); + $path = \preg_replace('|(?<=.)/+|', '/', $path); + if (\substr($path, 1, 1) === ':') { + $path = \ucfirst($path); + } + return $path; + } + /** + * Check if the path points to a plugin file. + * + * @param string $absolutePath Normalized path. + * @return bool + */ + protected static function isPluginFile($absolutePath) + { + //Is the file inside the "plugins" or "mu-plugins" directory? + $pluginDir = self::normalizePath(WP_PLUGIN_DIR); + $muPluginDir = self::normalizePath(WPMU_PLUGIN_DIR); + if (\strpos($absolutePath, $pluginDir) === 0 || \strpos($absolutePath, $muPluginDir) === 0) { + return \true; + } + //Is it a file at all? Caution: is_file() can fail if the parent dir. doesn't have the +x permission set. + if (!\is_file($absolutePath)) { + return \false; + } + //Does it have a valid plugin header? + //This is a last-ditch check for plugins symlinked from outside the WP root. + if (\function_exists('WCPOS\\Vendor\\get_file_data')) { + $headers = get_file_data($absolutePath, array('Name' => 'Plugin Name'), 'plugin'); + return !empty($headers['Name']); + } + return \false; + } + /** + * Get the name of the theme's directory from a full path to a file inside that directory. + * E.g. "/abc/public_html/wp-content/themes/foo/whatever.php" => "foo". + * + * Note that subdirectories are currently not supported. For example, + * "/xyz/wp-content/themes/my-theme/includes/whatever.php" => NULL. + * + * @param string $absolutePath Normalized path. + * @return string|null Directory name, or NULL if the path doesn't point to a theme. + */ + protected static function getThemeDirectoryName($absolutePath) + { + if (\is_file($absolutePath)) { + $absolutePath = \dirname($absolutePath); + } + if (\file_exists($absolutePath . '/style.css')) { + return \basename($absolutePath); + } + return null; + } + /** + * Get the service URI from the file header. + * + * @param string $fullPath + * @return string + */ + private static function getServiceURI($fullPath) + { + //Look for the URI + if (\is_readable($fullPath)) { + $seek = array('github' => 'GitHub URI', 'gitlab' => 'GitLab URI', 'bucket' => 'BitBucket URI'); + $seek = apply_filters('puc_get_source_uri', $seek); + $data = get_file_data($fullPath, $seek); + foreach ($data as $key => $uri) { + if ($uri) { + return $uri; + } + } + } + //URI was not found so throw an error. + throw new \RuntimeException(\sprintf('Unable to locate URI in header of "%s"', \htmlentities($fullPath))); + } + /** + * Get the name of the hosting service that the URL points to. + * + * @param string $metadataUrl + * @return string|null + */ + private static function getVcsService($metadataUrl) + { + $service = null; + //Which hosting service does the URL point to? + $host = (string) wp_parse_url($metadataUrl, \PHP_URL_HOST); + $path = (string) wp_parse_url($metadataUrl, \PHP_URL_PATH); + //Check if the path looks like "/user-name/repository". + //For GitLab.com it can also be "/user/group1/group2/.../repository". + $repoRegex = '@^/?([^/]+?)/([^/#?&]+?)/?$@'; + if ($host === 'gitlab.com') { + $repoRegex = '@^/?(?:[^/#?&]++/){1,20}(?:[^/#?&]++)/?$@'; + } + if (\preg_match($repoRegex, $path)) { + $knownServices = array('github.com' => 'GitHub', 'bitbucket.org' => 'BitBucket', 'gitlab.com' => 'GitLab'); + if (isset($knownServices[$host])) { + $service = $knownServices[$host]; + } + } + return apply_filters('puc_get_vcs_service', $service, $host, $path, $metadataUrl); + } + /** + * Get the latest version of the specified class that has the same major version number + * as this factory class. + * + * @param string $class Partial class name. + * @return string|null Full class name. + */ + protected static function getCompatibleClassVersion($class) + { + if (isset(self::$classVersions[$class][self::$latestCompatibleVersion])) { + return self::$classVersions[$class][self::$latestCompatibleVersion]; + } + return null; + } + /** + * Get the specific class name for the latest available version of a class. + * + * @param string $class + * @return null|string + */ + public static function getLatestClassVersion($class) + { + if (!self::$sorted) { + self::sortVersions(); + } + if (isset(self::$classVersions[$class])) { + return \reset(self::$classVersions[$class]); + } else { + return null; + } + } + /** + * Sort available class versions in descending order (i.e. newest first). + */ + protected static function sortVersions() + { + foreach (self::$classVersions as $class => $versions) { + \uksort($versions, array(__CLASS__, 'compareVersions')); + self::$classVersions[$class] = $versions; + } + self::$sorted = \true; + } + protected static function compareVersions($a, $b) + { + return -\version_compare($a, $b); + } + /** + * Register a version of a class. + * + * @access private This method is only for internal use by the library. + * + * @param string $generalClass Class name without version numbers, e.g. 'PluginUpdateChecker'. + * @param string $versionedClass Actual class name, e.g. 'PluginUpdateChecker_1_2'. + * @param string $version Version number, e.g. '1.2'. + */ + public static function addVersion($generalClass, $versionedClass, $version) + { + if (empty(self::$myMajorVersion)) { + $lastNamespaceSegment = \substr(__NAMESPACE__, \strrpos(__NAMESPACE__, '\\') + 1); + self::$myMajorVersion = \substr(\ltrim($lastNamespaceSegment, 'v'), 0, 1); + } + //Store the greatest version number that matches our major version. + $components = \explode('.', $version); + if ($components[0] === self::$myMajorVersion) { + if (empty(self::$latestCompatibleVersion) || \version_compare($version, self::$latestCompatibleVersion, '>')) { + self::$latestCompatibleVersion = $version; + } + } + if (!isset(self::$classVersions[$generalClass])) { + self::$classVersions[$generalClass] = array(); + } + self::$classVersions[$generalClass][$version] = $versionedClass; + self::$sorted = \false; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Scheduler.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Scheduler.php new file mode 100644 index 0000000..cc4eb12 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Scheduler.php @@ -0,0 +1,243 @@ +updateChecker = $updateChecker; + $this->checkPeriod = $checkPeriod; + //Set up the periodic update checks + $this->cronHook = $this->updateChecker->getUniqueName('cron_check_updates'); + if ($this->checkPeriod > 0) { + //Trigger the check via Cron. + //Try to use one of the default schedules if possible as it's less likely to conflict + //with other plugins and their custom schedules. + $defaultSchedules = array(1 => 'hourly', 12 => 'twicedaily', 24 => 'daily'); + if (\array_key_exists($this->checkPeriod, $defaultSchedules)) { + $scheduleName = $defaultSchedules[$this->checkPeriod]; + } else { + //Use a custom cron schedule. + $scheduleName = 'every' . $this->checkPeriod . 'hours'; + //phpcs:ignore WordPress.WP.CronInterval.ChangeDetected -- WPCS fails to parse the callback. + add_filter('cron_schedules', array($this, '_addCustomSchedule')); + } + if (!wp_next_scheduled($this->cronHook) && !\defined('WP_INSTALLING')) { + //Randomly offset the schedule to help prevent update server traffic spikes. Without this + //most checks may happen during times of day when people are most likely to install new plugins. + $upperLimit = \max($this->checkPeriod * 3600 - 15 * 60, 1); + if (\function_exists('WCPOS\\Vendor\\wp_rand')) { + $randomOffset = wp_rand(0, $upperLimit); + } else { + //This constructor may be called before wp_rand() is available. + //phpcs:ignore WordPress.WP.AlternativeFunctions.rand_rand + $randomOffset = \rand(0, $upperLimit); + } + $firstCheckTime = \time() - $randomOffset; + $firstCheckTime = apply_filters($this->updateChecker->getUniqueName('first_check_time'), $firstCheckTime); + wp_schedule_event($firstCheckTime, $scheduleName, $this->cronHook); + } + add_action($this->cronHook, array($this, 'maybeCheckForUpdates')); + //In case Cron is disabled or unreliable, we also manually trigger + //the periodic checks while the user is browsing the Dashboard. + add_action('admin_init', array($this, 'maybeCheckForUpdates')); + //Like WordPress itself, we check more often on certain pages. + /** @see wp_update_plugins */ + add_action('load-update-core.php', array($this, 'maybeCheckForUpdates')); + //phpcs:ignore Squiz.PHP.CommentedOutCode.Found -- Not actually code, just file names. + //"load-update.php" and "load-plugins.php" or "load-themes.php". + $this->hourlyCheckHooks = \array_merge($this->hourlyCheckHooks, $hourlyHooks); + foreach ($this->hourlyCheckHooks as $hook) { + add_action($hook, array($this, 'maybeCheckForUpdates')); + } + //This hook fires after a bulk update is complete. + add_action('upgrader_process_complete', array($this, 'upgraderProcessComplete'), 11, 2); + } else { + //Periodic checks are disabled. + wp_clear_scheduled_hook($this->cronHook); + } + } + /** + * Runs upon the WP action upgrader_process_complete. + * + * We look at the parameters to decide whether to call maybeCheckForUpdates() or not. + * We also check if the update checker has been removed by the update. + * + * @param \WP_Upgrader $upgrader WP_Upgrader instance + * @param array $upgradeInfo extra information about the upgrade + */ + public function upgraderProcessComplete( + /** @noinspection PhpUnusedParameterInspection */ + $upgrader, + $upgradeInfo + ) + { + //Cancel all further actions if the current version of PUC has been deleted or overwritten + //by a different version during the upgrade. If we try to do anything more in that situation, + //we could trigger a fatal error by trying to autoload a deleted class. + \clearstatcache(); + if (!\file_exists(__FILE__)) { + $this->removeHooks(); + $this->updateChecker->removeHooks(); + return; + } + //Sanity check and limitation to relevant types. + if (!\is_array($upgradeInfo) || !isset($upgradeInfo['type'], $upgradeInfo['action']) || 'update' !== $upgradeInfo['action'] || !\in_array($upgradeInfo['type'], array('plugin', 'theme'))) { + return; + } + //Filter out notifications of upgrades that should have no bearing upon whether or not our + //current info is up-to-date. + if (\is_a($this->updateChecker, \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Theme\UpdateChecker::class)) { + if ('theme' !== $upgradeInfo['type'] || !isset($upgradeInfo['themes'])) { + return; + } + //Letting too many things going through for checks is not a real problem, so we compare widely. + if (!\in_array(\strtolower($this->updateChecker->directoryName), \array_map('strtolower', $upgradeInfo['themes']))) { + return; + } + } + if (\is_a($this->updateChecker, \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Plugin\UpdateChecker::class)) { + if ('plugin' !== $upgradeInfo['type'] || !isset($upgradeInfo['plugins'])) { + return; + } + //Themes pass in directory names in the information array, but plugins use the relative plugin path. + if (!\in_array(\strtolower($this->updateChecker->directoryName), \array_map('dirname', \array_map('strtolower', $upgradeInfo['plugins'])))) { + return; + } + } + $this->maybeCheckForUpdates(); + } + /** + * Check for updates if the configured check interval has already elapsed. + * Will use a shorter check interval on certain admin pages like "Dashboard -> Updates" or when doing cron. + * + * You can override the default behaviour by using the "puc_check_now-$slug" filter. + * The filter callback will be passed three parameters: + * - Current decision. TRUE = check updates now, FALSE = don't check now. + * - Last check time as a Unix timestamp. + * - Configured check period in hours. + * Return TRUE to check for updates immediately, or FALSE to cancel. + * + * This method is declared public because it's a hook callback. Calling it directly is not recommended. + */ + public function maybeCheckForUpdates() + { + if (empty($this->checkPeriod)) { + return; + } + $state = $this->updateChecker->getUpdateState(); + $shouldCheck = $state->timeSinceLastCheck() >= $this->getEffectiveCheckPeriod(); + //Let plugin authors substitute their own algorithm. + $shouldCheck = apply_filters($this->updateChecker->getUniqueName('check_now'), $shouldCheck, $state->getLastCheck(), $this->checkPeriod); + if ($shouldCheck) { + $this->updateChecker->checkForUpdates(); + } + } + /** + * Calculate the actual check period based on the current status and environment. + * + * @return int Check period in seconds. + */ + protected function getEffectiveCheckPeriod() + { + $currentFilter = current_filter(); + if (\in_array($currentFilter, array('load-update-core.php', 'upgrader_process_complete'))) { + //Check more often when the user visits "Dashboard -> Updates" or does a bulk update. + $period = 60; + } else { + if (\in_array($currentFilter, $this->hourlyCheckHooks)) { + //Also check more often on /wp-admin/update.php and the "Plugins" or "Themes" page. + $period = 3600; + } else { + if ($this->throttleRedundantChecks && $this->updateChecker->getUpdate() !== null) { + //Check less frequently if it's already known that an update is available. + $period = $this->throttledCheckPeriod * 3600; + } else { + if (\defined('DOING_CRON') && \constant('DOING_CRON')) { + //WordPress cron schedules are not exact, so lets do an update check even + //if slightly less than $checkPeriod hours have elapsed since the last check. + $cronFuzziness = 20 * 60; + $period = $this->checkPeriod * 3600 - $cronFuzziness; + } else { + $period = $this->checkPeriod * 3600; + } + } + } + } + return $period; + } + /** + * Add our custom schedule to the array of Cron schedules used by WP. + * + * @param array $schedules + * @return array + */ + public function _addCustomSchedule($schedules) + { + if ($this->checkPeriod && $this->checkPeriod > 0) { + $scheduleName = 'every' . $this->checkPeriod . 'hours'; + $schedules[$scheduleName] = array('interval' => $this->checkPeriod * 3600, 'display' => \sprintf('Every %d hours', $this->checkPeriod)); + } + return $schedules; + } + /** + * Remove the scheduled cron event that the library uses to check for updates. + * + * @return void + */ + public function removeUpdaterCron() + { + wp_clear_scheduled_hook($this->cronHook); + } + /** + * Get the name of the update checker's WP-cron hook. Mostly useful for debugging. + * + * @return string + */ + public function getCronHookName() + { + return $this->cronHook; + } + /** + * Remove most hooks added by the scheduler. + */ + public function removeHooks() + { + remove_filter('cron_schedules', array($this, '_addCustomSchedule')); + remove_action('admin_init', array($this, 'maybeCheckForUpdates')); + remove_action('load-update-core.php', array($this, 'maybeCheckForUpdates')); + if ($this->cronHook !== null) { + remove_action($this->cronHook, array($this, 'maybeCheckForUpdates')); + } + if (!empty($this->hourlyCheckHooks)) { + foreach ($this->hourlyCheckHooks as $hook) { + remove_action($hook, array($this, 'maybeCheckForUpdates')); + } + } + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/StateStore.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/StateStore.php new file mode 100644 index 0000000..a85f1d0 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/StateStore.php @@ -0,0 +1,196 @@ +optionName = $optionName; + } + /** + * Get time elapsed since the last update check. + * + * If there are no recorded update checks, this method returns a large arbitrary number + * (i.e. time since the Unix epoch). + * + * @return int Elapsed time in seconds. + */ + public function timeSinceLastCheck() + { + $this->lazyLoad(); + return \time() - $this->lastCheck; + } + /** + * @return int + */ + public function getLastCheck() + { + $this->lazyLoad(); + return $this->lastCheck; + } + /** + * Set the time of the last update check to the current timestamp. + * + * @return $this + */ + public function setLastCheckToNow() + { + $this->lazyLoad(); + $this->lastCheck = \time(); + return $this; + } + /** + * @return null|Update + */ + public function getUpdate() + { + $this->lazyLoad(); + return $this->update; + } + /** + * @param Update|null $update + * @return $this + */ + public function setUpdate(\WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Update $update = null) + { + $this->lazyLoad(); + $this->update = $update; + return $this; + } + /** + * @return string + */ + public function getCheckedVersion() + { + $this->lazyLoad(); + return $this->checkedVersion; + } + /** + * @param string $version + * @return $this + */ + public function setCheckedVersion($version) + { + $this->lazyLoad(); + $this->checkedVersion = \strval($version); + return $this; + } + /** + * Get translation updates. + * + * @return array + */ + public function getTranslations() + { + $this->lazyLoad(); + if (isset($this->update, $this->update->translations)) { + return $this->update->translations; + } + return array(); + } + /** + * Set translation updates. + * + * @param array $translationUpdates + */ + public function setTranslations($translationUpdates) + { + $this->lazyLoad(); + if (isset($this->update)) { + $this->update->translations = $translationUpdates; + $this->save(); + } + } + public function save() + { + $state = new \stdClass(); + $state->lastCheck = $this->lastCheck; + $state->checkedVersion = $this->checkedVersion; + if (isset($this->update)) { + $state->update = $this->update->toStdClass(); + $updateClass = \get_class($this->update); + $state->updateClass = $updateClass; + $prefix = $this->getLibPrefix(); + if (\WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Utils::startsWith($updateClass, $prefix)) { + $state->updateBaseClass = \substr($updateClass, \strlen($prefix)); + } + } + update_site_option($this->optionName, $state); + $this->isLoaded = \true; + } + /** + * @return $this + */ + public function lazyLoad() + { + if (!$this->isLoaded) { + $this->load(); + } + return $this; + } + protected function load() + { + $this->isLoaded = \true; + $state = get_site_option($this->optionName, null); + if (!\is_object($state)) { + $this->lastCheck = 0; + $this->checkedVersion = ''; + $this->update = null; + return; + } + $this->lastCheck = \intval(\WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Utils::get($state, 'lastCheck', 0)); + $this->checkedVersion = \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Utils::get($state, 'checkedVersion', ''); + $this->update = null; + if (isset($state->update)) { + //This mess is due to the fact that the want the update class from this version + //of the library, not the version that saved the update. + $updateClass = null; + if (isset($state->updateBaseClass)) { + $updateClass = $this->getLibPrefix() . $state->updateBaseClass; + } else { + if (isset($state->updateClass)) { + $updateClass = $state->updateClass; + } + } + $factory = array($updateClass, 'fromObject'); + if ($updateClass !== null && \is_callable($factory)) { + $this->update = \call_user_func($factory, $state->update); + } + } + } + public function delete() + { + delete_site_option($this->optionName); + $this->lastCheck = 0; + $this->checkedVersion = ''; + $this->update = null; + } + private function getLibPrefix() + { + //This assumes that the current class is at the top of the versioned namespace. + return __NAMESPACE__ . '\\'; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Theme/Package.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Theme/Package.php new file mode 100644 index 0000000..c82e3c2 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Theme/Package.php @@ -0,0 +1,55 @@ +stylesheet = $stylesheet; + $this->theme = wp_get_theme($this->stylesheet); + parent::__construct($updateChecker); + } + public function getInstalledVersion() + { + return $this->theme->get('Version'); + } + public function getAbsoluteDirectoryPath() + { + if (\method_exists($this->theme, 'get_stylesheet_directory')) { + return $this->theme->get_stylesheet_directory(); + //Available since WP 3.4. + } + return get_theme_root($this->stylesheet) . '/' . $this->stylesheet; + } + /** + * Get the value of a specific plugin or theme header. + * + * @param string $headerName + * @param string $defaultValue + * @return string Either the value of the header, or $defaultValue if the header doesn't exist or is empty. + */ + public function getHeaderValue($headerName, $defaultValue = '') + { + $value = $this->theme->get($headerName); + if ($headerName === \false || $headerName === '') { + return $defaultValue; + } + return $value; + } + protected function getHeaderNames() + { + return array('Name' => 'Theme Name', 'ThemeURI' => 'Theme URI', 'Description' => 'Description', 'Author' => 'Author', 'AuthorURI' => 'Author URI', 'Version' => 'Version', 'Template' => 'Template', 'Status' => 'Status', 'Tags' => 'Tags', 'TextDomain' => 'Text Domain', 'DomainPath' => 'Domain Path'); + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Theme/Update.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Theme/Update.php new file mode 100644 index 0000000..92fef3c --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Theme/Update.php @@ -0,0 +1,76 @@ + $this->slug, 'new_version' => $this->version, 'url' => $this->details_url); + if (!empty($this->download_url)) { + $update['package'] = $this->download_url; + } + return $update; + } + /** + * Create a new instance of Theme_Update from its JSON-encoded representation. + * + * @param string $json Valid JSON string representing a theme information object. + * @return self New instance of ThemeUpdate, or NULL on error. + */ + public static function fromJson($json) + { + $instance = new self(); + if (!parent::createFromJson($json, $instance)) { + return null; + } + return $instance; + } + /** + * Create a new instance by copying the necessary fields from another object. + * + * @param \StdClass|self $object The source object. + * @return self The new copy. + */ + public static function fromObject($object) + { + $update = new self(); + $update->copyFields($object, $update); + return $update; + } + /** + * Basic validation. + * + * @param \StdClass $apiResponse + * @return bool|\WP_Error + */ + protected function validateMetadata($apiResponse) + { + $required = array('version', 'details_url'); + foreach ($required as $key) { + if (!isset($apiResponse->{$key}) || empty($apiResponse->{$key})) { + return new \WCPOS\Vendor\WP_Error('tuc-invalid-metadata', \sprintf('The theme metadata is missing the required "%s" key.', $key)); + } + } + return \true; + } + protected function getFieldNames() + { + return \array_merge(parent::getFieldNames(), self::$extraFields); + } + protected function getPrefixedFilter($tag) + { + return parent::getPrefixedFilter($tag) . '_theme'; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Theme/UpdateChecker.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Theme/UpdateChecker.php new file mode 100644 index 0000000..0becaa4 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Theme/UpdateChecker.php @@ -0,0 +1,141 @@ +stylesheet = $stylesheet; + parent::__construct($metadataUrl, $stylesheet, $customSlug ? $customSlug : $stylesheet, $checkPeriod, $optionName); + } + /** + * For themes, the update array is indexed by theme directory name. + * + * @return string + */ + protected function getUpdateListKey() + { + return $this->directoryName; + } + /** + * Retrieve the latest update (if any) from the configured API endpoint. + * + * @return Update|null An instance of Update, or NULL when no updates are available. + */ + public function requestUpdate() + { + list($themeUpdate, $result) = $this->requestMetadata(\WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Theme\Update::class, 'request_update'); + if ($themeUpdate !== null) { + /** @var Update $themeUpdate */ + $themeUpdate->slug = $this->slug; + } + $themeUpdate = $this->filterUpdateResult($themeUpdate, $result); + return $themeUpdate; + } + protected function getNoUpdateItemFields() + { + return \array_merge(parent::getNoUpdateItemFields(), array('theme' => $this->directoryName, 'requires' => '')); + } + public function userCanInstallUpdates() + { + return current_user_can('update_themes'); + } + /** + * Create an instance of the scheduler. + * + * @param int $checkPeriod + * @return Scheduler + */ + protected function createScheduler($checkPeriod) + { + return new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Scheduler($this, $checkPeriod, array('load-themes.php')); + } + /** + * Is there an update being installed right now for this theme? + * + * @param \WP_Upgrader|null $upgrader The upgrader that's performing the current update. + * @return bool + */ + public function isBeingUpgraded($upgrader = null) + { + return $this->upgraderStatus->isThemeBeingUpgraded($this->stylesheet, $upgrader); + } + protected function createDebugBarExtension() + { + return new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\DebugBar\Extension($this, \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\DebugBar\ThemePanel::class); + } + /** + * Register a callback for filtering query arguments. + * + * The callback function should take one argument - an associative array of query arguments. + * It should return a modified array of query arguments. + * + * @param callable $callback + * @return void + */ + public function addQueryArgFilter($callback) + { + $this->addFilter('request_update_query_args', $callback); + } + /** + * Register a callback for filtering arguments passed to wp_remote_get(). + * + * The callback function should take one argument - an associative array of arguments - + * and return a modified array or arguments. See the WP documentation on wp_remote_get() + * for details on what arguments are available and how they work. + * + * @uses add_filter() This method is a convenience wrapper for add_filter(). + * + * @param callable $callback + * @return void + */ + public function addHttpRequestArgFilter($callback) + { + $this->addFilter('request_update_options', $callback); + } + /** + * Register a callback for filtering theme updates retrieved from the external API. + * + * The callback function should take two arguments. If the theme update was retrieved + * successfully, the first argument passed will be an instance of Theme_Update. Otherwise, + * it will be NULL. The second argument will be the corresponding return value of + * wp_remote_get (see WP docs for details). + * + * The callback function should return a new or modified instance of Theme_Update or NULL. + * + * @uses add_filter() This method is a convenience wrapper for add_filter(). + * + * @param callable $callback + * @return void + */ + public function addResultFilter($callback) + { + $this->addFilter('request_update_result', $callback, 10, 2); + } + /** + * Create a package instance that represents this plugin or theme. + * + * @return InstalledPackage + */ + protected function createInstalledPackage() + { + return new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Theme\Package($this->stylesheet, $this); + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Update.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Update.php new file mode 100644 index 0000000..52e21d9 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Update.php @@ -0,0 +1,35 @@ +slug = $this->slug; + $update->new_version = $this->version; + $update->package = $this->download_url; + return $update; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/UpdateChecker.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/UpdateChecker.php new file mode 100644 index 0000000..5d9328a --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/UpdateChecker.php @@ -0,0 +1,849 @@ +debugMode = (bool) \constant('WP_DEBUG'); + $this->metadataUrl = $metadataUrl; + $this->directoryName = $directoryName; + $this->slug = !empty($slug) ? $slug : $this->directoryName; + $this->optionName = $optionName; + if (empty($this->optionName)) { + //BC: Initially the library only supported plugin updates and didn't use type prefixes + //in the option name. Lets use the same prefix-less name when possible. + if ($this->filterSuffix === '') { + $this->optionName = 'external_updates-' . $this->slug; + } else { + $this->optionName = $this->getUniqueName('external_updates'); + } + } + $this->package = $this->createInstalledPackage(); + $this->scheduler = $this->createScheduler($checkPeriod); + $this->upgraderStatus = new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\UpgraderStatus(); + $this->updateState = new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\StateStore($this->optionName); + if (did_action('init')) { + $this->loadTextDomain(); + } else { + add_action('init', array($this, 'loadTextDomain')); + } + $this->installHooks(); + } + /** + * @internal + */ + public function loadTextDomain() + { + //We're not using load_plugin_textdomain() or its siblings because figuring out where + //the library is located (plugin, mu-plugin, theme, custom wp-content paths) is messy. + $domain = 'plugin-update-checker'; + $locale = apply_filters('plugin_locale', is_admin() && \function_exists('WCPOS\\Vendor\\get_user_locale') ? get_user_locale() : get_locale(), $domain); + $moFile = $domain . '-' . $locale . '.mo'; + $path = \realpath(\dirname(__FILE__) . '/../../languages'); + if ($path && \file_exists($path)) { + load_textdomain($domain, $path . '/' . $moFile); + } + } + protected function installHooks() + { + //Insert our update info into the update array maintained by WP. + add_filter('site_transient_' . $this->updateTransient, array($this, 'injectUpdate')); + //Insert translation updates into the update list. + add_filter('site_transient_' . $this->updateTransient, array($this, 'injectTranslationUpdates')); + //Clear translation updates when WP clears the update cache. + //This needs to be done directly because the library doesn't actually remove obsolete plugin updates, + //it just hides them (see getUpdate()). We can't do that with translations - too much disk I/O. + add_action('delete_site_transient_' . $this->updateTransient, array($this, 'clearCachedTranslationUpdates')); + //Rename the update directory to be the same as the existing directory. + if ($this->directoryName !== '.') { + add_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10, 3); + } + //Allow HTTP requests to the metadata URL even if it's on a local host. + add_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10, 2); + //DebugBar integration. + if (did_action('plugins_loaded')) { + $this->maybeInitDebugBar(); + } else { + add_action('plugins_loaded', array($this, 'maybeInitDebugBar')); + } + } + /** + * Remove hooks that were added by this update checker instance. + */ + public function removeHooks() + { + remove_filter('site_transient_' . $this->updateTransient, array($this, 'injectUpdate')); + remove_filter('site_transient_' . $this->updateTransient, array($this, 'injectTranslationUpdates')); + remove_action('delete_site_transient_' . $this->updateTransient, array($this, 'clearCachedTranslationUpdates')); + remove_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10); + remove_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10); + remove_action('plugins_loaded', array($this, 'maybeInitDebugBar')); + remove_action('init', array($this, 'loadTextDomain')); + if ($this->scheduler) { + $this->scheduler->removeHooks(); + } + if ($this->debugBarExtension) { + $this->debugBarExtension->removeHooks(); + } + } + /** + * Check if the current user has the required permissions to install updates. + * + * @return bool + */ + public abstract function userCanInstallUpdates(); + /** + * Explicitly allow HTTP requests to the metadata URL. + * + * WordPress has a security feature where the HTTP API will reject all requests that are sent to + * another site hosted on the same server as the current site (IP match), a local host, or a local + * IP, unless the host exactly matches the current site. + * + * This feature is opt-in (at least in WP 4.4). Apparently some people enable it. + * + * That can be a problem when you're developing your plugin and you decide to host the update information + * on the same server as your test site. Update requests will mysteriously fail. + * + * We fix that by adding an exception for the metadata host. + * + * @param bool $allow + * @param string $host + * @return bool + */ + public function allowMetadataHost($allow, $host) + { + if ($this->cachedMetadataHost === 0) { + $this->cachedMetadataHost = wp_parse_url($this->metadataUrl, \PHP_URL_HOST); + } + if (\is_string($this->cachedMetadataHost) && \strtolower($host) === \strtolower($this->cachedMetadataHost)) { + return \true; + } + return $allow; + } + /** + * Create a package instance that represents this plugin or theme. + * + * @return InstalledPackage + */ + protected abstract function createInstalledPackage(); + /** + * @return InstalledPackage + */ + public function getInstalledPackage() + { + return $this->package; + } + /** + * Create an instance of the scheduler. + * + * This is implemented as a method to make it possible for plugins to subclass the update checker + * and substitute their own scheduler. + * + * @param int $checkPeriod + * @return Scheduler + */ + protected abstract function createScheduler($checkPeriod); + /** + * Check for updates. The results are stored in the DB option specified in $optionName. + * + * @return Update|null + */ + public function checkForUpdates() + { + $installedVersion = $this->getInstalledVersion(); + //Fail silently if we can't find the plugin/theme or read its header. + if ($installedVersion === null) { + $this->triggerError(\sprintf('Skipping update check for %s - installed version unknown.', $this->slug), \E_USER_WARNING); + return null; + } + //Start collecting API errors. + $this->lastRequestApiErrors = array(); + add_action('puc_api_error', array($this, 'collectApiErrors'), 10, 4); + $state = $this->updateState; + $state->setLastCheckToNow()->setCheckedVersion($installedVersion)->save(); + //Save before checking in case something goes wrong + $state->setUpdate($this->requestUpdate()); + $state->save(); + //Stop collecting API errors. + remove_action('puc_api_error', array($this, 'collectApiErrors'), 10); + return $this->getUpdate(); + } + /** + * Load the update checker state from the DB. + * + * @return StateStore + */ + public function getUpdateState() + { + return $this->updateState->lazyLoad(); + } + /** + * Reset update checker state - i.e. last check time, cached update data and so on. + * + * Call this when your plugin is being uninstalled, or if you want to + * clear the update cache. + */ + public function resetUpdateState() + { + $this->updateState->delete(); + } + /** + * Get the details of the currently available update, if any. + * + * If no updates are available, or if the last known update version is below or equal + * to the currently installed version, this method will return NULL. + * + * Uses cached update data. To retrieve update information straight from + * the metadata URL, call requestUpdate() instead. + * + * @return Update|null + */ + public function getUpdate() + { + $update = $this->updateState->getUpdate(); + //Is there an update available? + if (isset($update)) { + //Check if the update is actually newer than the currently installed version. + $installedVersion = $this->getInstalledVersion(); + if ($installedVersion !== null && \version_compare($update->version, $installedVersion, '>')) { + return $update; + } + } + return null; + } + /** + * Retrieve the latest update (if any) from the configured API endpoint. + * + * Subclasses should run the update through filterUpdateResult before returning it. + * + * @return Update An instance of Update, or NULL when no updates are available. + */ + public abstract function requestUpdate(); + /** + * Filter the result of a requestUpdate() call. + * + * @template T of Update + * @param T|null $update + * @param array|WP_Error|null $httpResult The value returned by wp_remote_get(), if any. + * @return T + */ + protected function filterUpdateResult($update, $httpResult = null) + { + //Let plugins/themes modify the update. + $update = apply_filters($this->getUniqueName('request_update_result'), $update, $httpResult); + $this->fixSupportedWordpressVersion($update); + if (isset($update, $update->translations)) { + //Keep only those translation updates that apply to this site. + $update->translations = $this->filterApplicableTranslations($update->translations); + } + return $update; + } + /** + * The "Tested up to" field in the plugin metadata is supposed to be in the form of "major.minor", + * while WordPress core's list_plugin_updates() expects the $update->tested field to be an exact + * version, e.g. "major.minor.patch", to say it's compatible. In other case it shows + * "Compatibility: Unknown". + * The function mimics how wordpress.org API crafts the "tested" field out of "Tested up to". + * + * @param Metadata|null $update + */ + protected function fixSupportedWordpressVersion(\WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Metadata $update = null) + { + if (!isset($update->tested) || !\preg_match('/^\\d++\\.\\d++$/', $update->tested)) { + return; + } + $actualWpVersions = array(); + $wpVersion = $GLOBALS['wp_version']; + if (\function_exists('WCPOS\\Vendor\\get_core_updates')) { + $coreUpdates = get_core_updates(); + if (\is_array($coreUpdates)) { + foreach ($coreUpdates as $coreUpdate) { + if (isset($coreUpdate->current)) { + $actualWpVersions[] = $coreUpdate->current; + } + } + } + } + $actualWpVersions[] = $wpVersion; + $actualWpPatchNumber = null; + foreach ($actualWpVersions as $version) { + if (\preg_match('/^(?P\\d++\\.\\d++)(?:\\.(?P\\d++))?/', $version, $versionParts)) { + if ($versionParts['majorMinor'] === $update->tested) { + $patch = isset($versionParts['patch']) ? \intval($versionParts['patch']) : 0; + if ($actualWpPatchNumber === null) { + $actualWpPatchNumber = $patch; + } else { + $actualWpPatchNumber = \max($actualWpPatchNumber, $patch); + } + } + } + } + if ($actualWpPatchNumber === null) { + $actualWpPatchNumber = 999; + } + if ($actualWpPatchNumber > 0) { + $update->tested .= '.' . $actualWpPatchNumber; + } + } + /** + * Get the currently installed version of the plugin or theme. + * + * @return string|null Version number. + */ + public function getInstalledVersion() + { + return $this->package->getInstalledVersion(); + } + /** + * Get the full path of the plugin or theme directory. + * + * @return string + */ + public function getAbsoluteDirectoryPath() + { + return $this->package->getAbsoluteDirectoryPath(); + } + /** + * Trigger a PHP error, but only when $debugMode is enabled. + * + * @param string $message + * @param int $errorType + */ + public function triggerError($message, $errorType) + { + if ($this->isDebugModeEnabled()) { + //phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error -- Only happens in debug mode. + \trigger_error(esc_html($message), $errorType); + } + } + /** + * @return bool + */ + protected function isDebugModeEnabled() + { + if ($this->debugMode === null) { + $this->debugMode = (bool) \constant('WP_DEBUG'); + } + return $this->debugMode; + } + /** + * Get the full name of an update checker filter, action or DB entry. + * + * This method adds the "puc_" prefix and the "-$slug" suffix to the filter name. + * For example, "pre_inject_update" becomes "puc_pre_inject_update-plugin-slug". + * + * @param string $baseTag + * @return string + */ + public function getUniqueName($baseTag) + { + $name = 'puc_' . $baseTag; + if ($this->filterSuffix !== '') { + $name .= '_' . $this->filterSuffix; + } + return $name . '-' . $this->slug; + } + /** + * Store API errors that are generated when checking for updates. + * + * @internal + * @param \WP_Error $error + * @param array|null $httpResponse + * @param string|null $url + * @param string|null $slug + */ + public function collectApiErrors($error, $httpResponse = null, $url = null, $slug = null) + { + if (isset($slug) && $slug !== $this->slug) { + return; + } + $this->lastRequestApiErrors[] = array('error' => $error, 'httpResponse' => $httpResponse, 'url' => $url); + } + /** + * @return array + */ + public function getLastRequestApiErrors() + { + return $this->lastRequestApiErrors; + } + /* ------------------------------------------------------------------- + * PUC filters and filter utilities + * ------------------------------------------------------------------- + */ + /** + * Register a callback for one of the update checker filters. + * + * Identical to add_filter(), except it automatically adds the "puc_" prefix + * and the "-$slug" suffix to the filter name. For example, "request_info_result" + * becomes "puc_request_info_result-your_plugin_slug". + * + * @param string $tag + * @param callable $callback + * @param int $priority + * @param int $acceptedArgs + */ + public function addFilter($tag, $callback, $priority = 10, $acceptedArgs = 1) + { + add_filter($this->getUniqueName($tag), $callback, $priority, $acceptedArgs); + } + /* ------------------------------------------------------------------- + * Inject updates + * ------------------------------------------------------------------- + */ + /** + * Insert the latest update (if any) into the update list maintained by WP. + * + * @param \stdClass $updates Update list. + * @return \stdClass Modified update list. + */ + public function injectUpdate($updates) + { + //Is there an update to insert? + $update = $this->getUpdate(); + if (!$this->shouldShowUpdates()) { + $update = null; + } + if (!empty($update)) { + //Let plugins filter the update info before it's passed on to WordPress. + $update = apply_filters($this->getUniqueName('pre_inject_update'), $update); + $updates = $this->addUpdateToList($updates, $update->toWpFormat()); + } else { + //Clean up any stale update info. + $updates = $this->removeUpdateFromList($updates); + //Add a placeholder item to the "no_update" list to enable auto-update support. + //If we don't do this, the option to enable automatic updates will only show up + //when an update is available. + $updates = $this->addNoUpdateItem($updates); + } + return $updates; + } + /** + * @param \stdClass|null $updates + * @param \stdClass|array $updateToAdd + * @return \stdClass + */ + protected function addUpdateToList($updates, $updateToAdd) + { + if (!\is_object($updates)) { + $updates = new \stdClass(); + $updates->response = array(); + } + $updates->response[$this->getUpdateListKey()] = $updateToAdd; + return $updates; + } + /** + * @param \stdClass|null $updates + * @return \stdClass|null + */ + protected function removeUpdateFromList($updates) + { + if (isset($updates, $updates->response)) { + unset($updates->response[$this->getUpdateListKey()]); + } + return $updates; + } + /** + * See this post for more information: + * @link https://make.wordpress.org/core/2020/07/30/recommended-usage-of-the-updates-api-to-support-the-auto-updates-ui-for-plugins-and-themes-in-wordpress-5-5/ + * + * @param \stdClass|null $updates + * @return \stdClass + */ + protected function addNoUpdateItem($updates) + { + if (!\is_object($updates)) { + $updates = new \stdClass(); + $updates->response = array(); + $updates->no_update = array(); + } else { + if (!isset($updates->no_update)) { + $updates->no_update = array(); + } + } + $updates->no_update[$this->getUpdateListKey()] = (object) $this->getNoUpdateItemFields(); + return $updates; + } + /** + * Subclasses should override this method to add fields that are specific to plugins or themes. + * @return array + */ + protected function getNoUpdateItemFields() + { + return array('new_version' => $this->getInstalledVersion(), 'url' => '', 'package' => '', 'requires_php' => ''); + } + /** + * Get the key that will be used when adding updates to the update list that's maintained + * by the WordPress core. The list is always an associative array, but the key is different + * for plugins and themes. + * + * @return string + */ + protected abstract function getUpdateListKey(); + /** + * Should we show available updates? + * + * Usually the answer is "yes", but there are exceptions. For example, WordPress doesn't + * support automatic updates installation for mu-plugins, so PUC usually won't show update + * notifications in that case. See the plugin-specific subclass for details. + * + * Note: This method only applies to updates that are displayed (or not) in the WordPress + * admin. It doesn't affect APIs like requestUpdate and getUpdate. + * + * @return bool + */ + protected function shouldShowUpdates() + { + return \true; + } + /* ------------------------------------------------------------------- + * JSON-based update API + * ------------------------------------------------------------------- + */ + /** + * Retrieve plugin or theme metadata from the JSON document at $this->metadataUrl. + * + * @param class-string $metaClass Parse the JSON as an instance of this class. It must have a static fromJson method. + * @param string $filterRoot + * @param array $queryArgs Additional query arguments. + * @return array A metadata instance and the value returned by wp_remote_get(). + */ + protected function requestMetadata($metaClass, $filterRoot, $queryArgs = array()) + { + //Query args to append to the URL. Plugins can add their own by using a filter callback (see addQueryArgFilter()). + $queryArgs = \array_merge(array('installed_version' => \strval($this->getInstalledVersion()), 'php' => \phpversion(), 'locale' => get_locale()), $queryArgs); + $queryArgs = apply_filters($this->getUniqueName($filterRoot . '_query_args'), $queryArgs); + //Various options for the wp_remote_get() call. Plugins can filter these, too. + $options = array('timeout' => wp_doing_cron() ? 10 : 3, 'headers' => array('Accept' => 'application/json')); + $options = apply_filters($this->getUniqueName($filterRoot . '_options'), $options); + //The metadata file should be at 'http://your-api.com/url/here/$slug/info.json' + $url = $this->metadataUrl; + if (!empty($queryArgs)) { + $url = add_query_arg($queryArgs, $url); + } + $result = wp_remote_get($url, $options); + $result = apply_filters($this->getUniqueName('request_metadata_http_result'), $result, $url, $options); + //Try to parse the response + $status = $this->validateApiResponse($result); + $metadata = null; + if (!is_wp_error($status)) { + if (\strpos($metaClass, '\\') === \false) { + $metaClass = __NAMESPACE__ . '\\' . $metaClass; + } + $metadata = \call_user_func(array($metaClass, 'fromJson'), $result['body']); + } else { + do_action('puc_api_error', $status, $result, $url, $this->slug); + $this->triggerError(\sprintf('The URL %s does not point to a valid metadata file. ', $url) . $status->get_error_message(), \E_USER_WARNING); + } + return array($metadata, $result); + } + /** + * Check if $result is a successful update API response. + * + * @param array|WP_Error $result + * @return true|WP_Error + */ + protected function validateApiResponse($result) + { + if (is_wp_error($result)) { + /** @var WP_Error $result */ + return new \WCPOS\Vendor\WP_Error($result->get_error_code(), 'WP HTTP Error: ' . $result->get_error_message()); + } + if (!isset($result['response']['code'])) { + return new \WCPOS\Vendor\WP_Error('puc_no_response_code', 'wp_remote_get() returned an unexpected result.'); + } + if ($result['response']['code'] !== 200) { + return new \WCPOS\Vendor\WP_Error('puc_unexpected_response_code', 'HTTP response code is ' . $result['response']['code'] . ' (expected: 200)'); + } + if (empty($result['body'])) { + return new \WCPOS\Vendor\WP_Error('puc_empty_response', 'The metadata file appears to be empty.'); + } + return \true; + } + /* ------------------------------------------------------------------- + * Language packs / Translation updates + * ------------------------------------------------------------------- + */ + /** + * Filter a list of translation updates and return a new list that contains only updates + * that apply to the current site. + * + * @param array $translations + * @return array + */ + protected function filterApplicableTranslations($translations) + { + $languages = \array_flip(\array_values(get_available_languages())); + $installedTranslations = $this->getInstalledTranslations(); + $applicableTranslations = array(); + foreach ($translations as $translation) { + //Does it match one of the available core languages? + $isApplicable = \array_key_exists($translation->language, $languages); + //Is it more recent than an already-installed translation? + if (isset($installedTranslations[$translation->language])) { + $updateTimestamp = \strtotime($translation->updated); + $installedTimestamp = \strtotime($installedTranslations[$translation->language]['PO-Revision-Date']); + $isApplicable = $updateTimestamp > $installedTimestamp; + } + if ($isApplicable) { + $applicableTranslations[] = $translation; + } + } + return $applicableTranslations; + } + /** + * Get a list of installed translations for this plugin or theme. + * + * @return array + */ + protected function getInstalledTranslations() + { + if (!\function_exists('WCPOS\\Vendor\\wp_get_installed_translations')) { + return array(); + } + $installedTranslations = wp_get_installed_translations($this->translationType . 's'); + if (isset($installedTranslations[$this->directoryName])) { + $installedTranslations = $installedTranslations[$this->directoryName]; + } else { + $installedTranslations = array(); + } + return $installedTranslations; + } + /** + * Insert translation updates into the list maintained by WordPress. + * + * @param stdClass $updates + * @return stdClass + */ + public function injectTranslationUpdates($updates) + { + $translationUpdates = $this->getTranslationUpdates(); + if (empty($translationUpdates)) { + return $updates; + } + //Being defensive. + if (!\is_object($updates)) { + $updates = new \stdClass(); + } + if (!isset($updates->translations)) { + $updates->translations = array(); + } + //In case there's a name collision with a plugin or theme hosted on wordpress.org, + //remove any preexisting updates that match our thing. + $updates->translations = \array_values(\array_filter($updates->translations, array($this, 'isNotMyTranslation'))); + //Add our updates to the list. + foreach ($translationUpdates as $update) { + $convertedUpdate = \array_merge(array( + 'type' => $this->translationType, + 'slug' => $this->directoryName, + 'autoupdate' => 0, + //AFAICT, WordPress doesn't actually use the "version" field for anything. + //But lets make sure it's there, just in case. + 'version' => isset($update->version) ? $update->version : '1.' . \strtotime($update->updated), + ), (array) $update); + $updates->translations[] = $convertedUpdate; + } + return $updates; + } + /** + * Get a list of available translation updates. + * + * This method will return an empty array if there are no updates. + * Uses cached update data. + * + * @return array + */ + public function getTranslationUpdates() + { + return $this->updateState->getTranslations(); + } + /** + * Remove all cached translation updates. + * + * @see wp_clean_update_cache + */ + public function clearCachedTranslationUpdates() + { + $this->updateState->setTranslations(array()); + } + /** + * Filter callback. Keeps only translations that *don't* match this plugin or theme. + * + * @param array $translation + * @return bool + */ + protected function isNotMyTranslation($translation) + { + $isMatch = isset($translation['type'], $translation['slug']) && $translation['type'] === $this->translationType && $translation['slug'] === $this->directoryName; + return !$isMatch; + } + /* ------------------------------------------------------------------- + * Fix directory name when installing updates + * ------------------------------------------------------------------- + */ + /** + * Rename the update directory to match the existing plugin/theme directory. + * + * When WordPress installs a plugin or theme update, it assumes that the ZIP file will contain + * exactly one directory, and that the directory name will be the same as the directory where + * the plugin or theme is currently installed. + * + * GitHub and other repositories provide ZIP downloads, but they often use directory names like + * "project-branch" or "project-tag-hash". We need to change the name to the actual plugin folder. + * + * This is a hook callback. Don't call it from a plugin. + * + * @access protected + * + * @param string $source The directory to copy to /wp-content/plugins or /wp-content/themes. Usually a subdirectory of $remoteSource. + * @param string $remoteSource WordPress has extracted the update to this directory. + * @param \WP_Upgrader $upgrader + * @return string|WP_Error + */ + public function fixDirectoryName($source, $remoteSource, $upgrader) + { + global $wp_filesystem; + /** @var \WP_Filesystem_Base $wp_filesystem */ + //Basic sanity checks. + if (!isset($source, $remoteSource, $upgrader, $upgrader->skin, $wp_filesystem)) { + return $source; + } + //If WordPress is upgrading anything other than our plugin/theme, leave the directory name unchanged. + if (!$this->isBeingUpgraded($upgrader)) { + return $source; + } + //Rename the source to match the existing directory. + $correctedSource = trailingslashit($remoteSource) . $this->directoryName . '/'; + if ($source !== $correctedSource) { + //The update archive should contain a single directory that contains the rest of plugin/theme files. + //Otherwise, WordPress will try to copy the entire working directory ($source == $remoteSource). + //We can't rename $remoteSource because that would break WordPress code that cleans up temporary files + //after update. + if ($this->isBadDirectoryStructure($remoteSource)) { + return new \WCPOS\Vendor\WP_Error('puc-incorrect-directory-structure', \sprintf('The directory structure of the update is incorrect. All files should be inside ' . 'a directory named %s, not at the root of the ZIP archive.', \htmlentities($this->slug))); + } + /** @var \WP_Upgrader_Skin $upgrader ->skin */ + $upgrader->skin->feedback(\sprintf('Renaming %s to %s…', '' . \basename($source) . '', '' . $this->directoryName . '')); + if ($wp_filesystem->move($source, $correctedSource, \true)) { + $upgrader->skin->feedback('Directory successfully renamed.'); + return $correctedSource; + } else { + return new \WCPOS\Vendor\WP_Error('puc-rename-failed', 'Unable to rename the update to match the existing directory.'); + } + } + return $source; + } + /** + * Is there an update being installed right now, for this plugin or theme? + * + * @param \WP_Upgrader|null $upgrader The upgrader that's performing the current update. + * @return bool + */ + public abstract function isBeingUpgraded($upgrader = null); + /** + * Check for incorrect update directory structure. An update must contain a single directory, + * all other files should be inside that directory. + * + * @param string $remoteSource Directory path. + * @return bool + */ + protected function isBadDirectoryStructure($remoteSource) + { + global $wp_filesystem; + /** @var \WP_Filesystem_Base $wp_filesystem */ + $sourceFiles = $wp_filesystem->dirlist($remoteSource); + if (\is_array($sourceFiles)) { + $sourceFiles = \array_keys($sourceFiles); + $firstFilePath = trailingslashit($remoteSource) . $sourceFiles[0]; + return \count($sourceFiles) > 1 || !$wp_filesystem->is_dir($firstFilePath); + } + //Assume it's fine. + return \false; + } + /* ------------------------------------------------------------------- + * DebugBar integration + * ------------------------------------------------------------------- + */ + /** + * Initialize the update checker Debug Bar plugin/add-on thingy. + */ + public function maybeInitDebugBar() + { + if (\class_exists('WCPOS\\Vendor\\Debug_Bar', \false) && \file_exists(\dirname(__FILE__) . '/DebugBar')) { + $this->debugBarExtension = $this->createDebugBarExtension(); + } + } + protected function createDebugBarExtension() + { + return new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\DebugBar\Extension($this); + } + /** + * Display additional configuration details in the Debug Bar panel. + * + * @param DebugBar\Panel $panel + */ + public function onDisplayConfiguration($panel) + { + //Do nothing. Subclasses can use this to add additional info to the panel. + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/UpgraderStatus.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/UpgraderStatus.php new file mode 100644 index 0000000..4de11c2 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/UpgraderStatus.php @@ -0,0 +1,196 @@ +isBeingUpgraded('plugin', $pluginFile, $upgrader); + } + /** + * Is there an update being installed for a specific theme? + * + * @param string $stylesheet Theme directory name. + * @param \WP_Upgrader|null $upgrader The upgrader that's performing the current update. + * @return bool + */ + public function isThemeBeingUpgraded($stylesheet, $upgrader = null) + { + return $this->isBeingUpgraded('theme', $stylesheet, $upgrader); + } + /** + * Check if a specific theme or plugin is being upgraded. + * + * @param string $type + * @param string $id + * @param \Plugin_Upgrader|\WP_Upgrader|null $upgrader + * @return bool + */ + protected function isBeingUpgraded($type, $id, $upgrader = null) + { + if (isset($upgrader)) { + list($currentType, $currentId) = $this->getThingBeingUpgradedBy($upgrader); + if ($currentType !== null) { + $this->currentType = $currentType; + $this->currentId = $currentId; + } + } + return $this->currentType === $type && $this->currentId === $id; + } + /** + * Figure out which theme or plugin is being upgraded by a WP_Upgrader instance. + * + * Returns an array with two items. The first item is the type of the thing that's being + * upgraded: "plugin" or "theme". The second item is either the plugin basename or + * the theme directory name. If we can't determine what the upgrader is doing, both items + * will be NULL. + * + * Examples: + * ['plugin', 'plugin-dir-name/plugin.php'] + * ['theme', 'theme-dir-name'] + * + * @param \Plugin_Upgrader|\WP_Upgrader $upgrader + * @return array + */ + private function getThingBeingUpgradedBy($upgrader) + { + if (!isset($upgrader, $upgrader->skin)) { + return array(null, null); + } + //Figure out which plugin or theme is being upgraded. + $pluginFile = null; + $themeDirectoryName = null; + $skin = $upgrader->skin; + if (isset($skin->theme_info) && $skin->theme_info instanceof \WCPOS\Vendor\WP_Theme) { + $themeDirectoryName = $skin->theme_info->get_stylesheet(); + } elseif ($skin instanceof \WCPOS\Vendor\Plugin_Upgrader_Skin) { + if (isset($skin->plugin) && \is_string($skin->plugin) && $skin->plugin !== '') { + $pluginFile = $skin->plugin; + } + } elseif ($skin instanceof \WCPOS\Vendor\Theme_Upgrader_Skin) { + if (isset($skin->theme) && \is_string($skin->theme) && $skin->theme !== '') { + $themeDirectoryName = $skin->theme; + } + } elseif (isset($skin->plugin_info) && \is_array($skin->plugin_info)) { + //This case is tricky because Bulk_Plugin_Upgrader_Skin (etc) doesn't actually store the plugin + //filename anywhere. Instead, it has the plugin headers in $plugin_info. So the best we can + //do is compare those headers to the headers of installed plugins. + $pluginFile = $this->identifyPluginByHeaders($skin->plugin_info); + } + if ($pluginFile !== null) { + return array('plugin', $pluginFile); + } elseif ($themeDirectoryName !== null) { + return array('theme', $themeDirectoryName); + } + return array(null, null); + } + /** + * Identify an installed plugin based on its headers. + * + * @param array $searchHeaders The plugin file header to look for. + * @return string|null Plugin basename ("foo/bar.php"), or NULL if we can't identify the plugin. + */ + private function identifyPluginByHeaders($searchHeaders) + { + if (!\function_exists('WCPOS\\Vendor\\get_plugins')) { + require_once ABSPATH . '/wp-admin/includes/plugin.php'; + } + $installedPlugins = get_plugins(); + $matches = array(); + foreach ($installedPlugins as $pluginBasename => $headers) { + $diff1 = \array_diff_assoc($headers, $searchHeaders); + $diff2 = \array_diff_assoc($searchHeaders, $headers); + if (empty($diff1) && empty($diff2)) { + $matches[] = $pluginBasename; + } + } + //It's possible (though very unlikely) that there could be two plugins with identical + //headers. In that case, we can't unambiguously identify the plugin that's being upgraded. + if (\count($matches) !== 1) { + return null; + } + return \reset($matches); + } + /** + * @access private + * + * @param mixed $input + * @param array $hookExtra + * @return mixed Returns $input unaltered. + */ + public function setUpgradedThing($input, $hookExtra) + { + if (!empty($hookExtra['plugin']) && \is_string($hookExtra['plugin'])) { + $this->currentId = $hookExtra['plugin']; + $this->currentType = 'plugin'; + } elseif (!empty($hookExtra['theme']) && \is_string($hookExtra['theme'])) { + $this->currentId = $hookExtra['theme']; + $this->currentType = 'theme'; + } else { + $this->currentType = null; + $this->currentId = null; + } + return $input; + } + /** + * @access private + * + * @param array $options + * @return array + */ + public function setUpgradedPluginFromOptions($options) + { + if (isset($options['hook_extra']['plugin']) && \is_string($options['hook_extra']['plugin'])) { + $this->currentType = 'plugin'; + $this->currentId = $options['hook_extra']['plugin']; + } else { + $this->currentType = null; + $this->currentId = null; + } + return $options; + } + /** + * @access private + * + * @param mixed $input + * @return mixed Returns $input unaltered. + */ + public function clearUpgradedThing($input = null) + { + $this->currentId = null; + $this->currentType = null; + return $input; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Utils.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Utils.php new file mode 100644 index 0000000..de554bb --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Utils.php @@ -0,0 +1,69 @@ +{$node})) { + $currentValue = $currentValue->{$node}; + } else { + return $default; + } + } + } + return $currentValue; + } + /** + * Get the first array element that is not empty. + * + * @param array $values + * @param mixed|null $default Returns this value if there are no non-empty elements. + * @return mixed|null + */ + public static function findNotEmpty($values, $default = null) + { + if (empty($values)) { + return $default; + } + foreach ($values as $value) { + if (!empty($value)) { + return $value; + } + } + return $default; + } + /** + * Check if the input string starts with the specified prefix. + * + * @param string $input + * @param string $prefix + * @return bool + */ + public static function startsWith($input, $prefix) + { + $length = \strlen($prefix); + return \substr($input, 0, $length) === $prefix; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/Api.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/Api.php new file mode 100644 index 0000000..a3da34b --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/Api.php @@ -0,0 +1,347 @@ +repositoryUrl = $repositoryUrl; + $this->setAuthentication($credentials); + } + /** + * @return string + */ + public function getRepositoryUrl() + { + return $this->repositoryUrl; + } + /** + * Figure out which reference (i.e. tag or branch) contains the latest version. + * + * @param string $configBranch Start looking in this branch. + * @return null|Reference + */ + public function chooseReference($configBranch) + { + $strategies = $this->getUpdateDetectionStrategies($configBranch); + if (!empty($this->strategyFilterName)) { + $strategies = apply_filters($this->strategyFilterName, $strategies, $this->slug); + } + foreach ($strategies as $strategy) { + $reference = \call_user_func($strategy); + if (!empty($reference)) { + return $reference; + } + } + return null; + } + /** + * Get an ordered list of strategies that can be used to find the latest version. + * + * The update checker will try each strategy in order until one of them + * returns a valid reference. + * + * @param string $configBranch + * @return array Array of callables that return Vcs_Reference objects. + */ + protected abstract function getUpdateDetectionStrategies($configBranch); + /** + * Get the readme.txt file from the remote repository and parse it + * according to the plugin readme standard. + * + * @param string $ref Tag or branch name. + * @return array Parsed readme. + */ + public function getRemoteReadme($ref = 'master') + { + $fileContents = $this->getRemoteFile($this->getLocalReadmeName(), $ref); + if (empty($fileContents)) { + return array(); + } + $parser = new \WCPOS\Vendor\PucReadmeParser(); + return $parser->parse_readme_contents($fileContents); + } + /** + * Get the case-sensitive name of the local readme.txt file. + * + * In most cases it should just be called "readme.txt", but some plugins call it "README.txt", + * "README.TXT", or even "Readme.txt". Most VCS are case-sensitive so we need to know the correct + * capitalization. + * + * Defaults to "readme.txt" (all lowercase). + * + * @return string + */ + public function getLocalReadmeName() + { + static $fileName = null; + if ($fileName !== null) { + return $fileName; + } + $fileName = 'readme.txt'; + if (isset($this->localDirectory)) { + $files = \scandir($this->localDirectory); + if (!empty($files)) { + foreach ($files as $possibleFileName) { + if (\strcasecmp($possibleFileName, 'readme.txt') === 0) { + $fileName = $possibleFileName; + break; + } + } + } + } + return $fileName; + } + /** + * Get a branch. + * + * @param string $branchName + * @return Reference|null + */ + public abstract function getBranch($branchName); + /** + * Get a specific tag. + * + * @param string $tagName + * @return Reference|null + */ + public abstract function getTag($tagName); + /** + * Get the tag that looks like the highest version number. + * (Implementations should skip pre-release versions if possible.) + * + * @return Reference|null + */ + public abstract function getLatestTag(); + /** + * Check if a tag name string looks like a version number. + * + * @param string $name + * @return bool + */ + protected function looksLikeVersion($name) + { + //Tag names may be prefixed with "v", e.g. "v1.2.3". + $name = \ltrim($name, 'v'); + //The version string must start with a number. + if (!\is_numeric(\substr($name, 0, 1))) { + return \false; + } + //The goal is to accept any SemVer-compatible or "PHP-standardized" version number. + return \preg_match('@^(\\d{1,5}?)(\\.\\d{1,10}?){0,4}?($|[abrdp+_\\-]|\\s)@i', $name) === 1; + } + /** + * Check if a tag appears to be named like a version number. + * + * @param \stdClass $tag + * @return bool + */ + protected function isVersionTag($tag) + { + $property = $this->tagNameProperty; + return isset($tag->{$property}) && $this->looksLikeVersion($tag->{$property}); + } + /** + * Sort a list of tags as if they were version numbers. + * Tags that don't look like version number will be removed. + * + * @param \stdClass[] $tags Array of tag objects. + * @return \stdClass[] Filtered array of tags sorted in descending order. + */ + protected function sortTagsByVersion($tags) + { + //Keep only those tags that look like version numbers. + $versionTags = \array_filter($tags, array($this, 'isVersionTag')); + //Sort them in descending order. + \usort($versionTags, array($this, 'compareTagNames')); + return $versionTags; + } + /** + * Compare two tags as if they were version number. + * + * @param \stdClass $tag1 Tag object. + * @param \stdClass $tag2 Another tag object. + * @return int + */ + protected function compareTagNames($tag1, $tag2) + { + $property = $this->tagNameProperty; + if (!isset($tag1->{$property})) { + return 1; + } + if (!isset($tag2->{$property})) { + return -1; + } + return -\version_compare(\ltrim($tag1->{$property}, 'v'), \ltrim($tag2->{$property}, 'v')); + } + /** + * Get the contents of a file from a specific branch or tag. + * + * @param string $path File name. + * @param string $ref + * @return null|string Either the contents of the file, or null if the file doesn't exist or there's an error. + */ + public abstract function getRemoteFile($path, $ref = 'master'); + /** + * Get the timestamp of the latest commit that changed the specified branch or tag. + * + * @param string $ref Reference name (e.g. branch or tag). + * @return string|null + */ + public abstract function getLatestCommitTime($ref); + /** + * Get the contents of the changelog file from the repository. + * + * @param string $ref + * @param string $localDirectory Full path to the local plugin or theme directory. + * @return null|string The HTML contents of the changelog. + */ + public function getRemoteChangelog($ref, $localDirectory) + { + $filename = $this->findChangelogName($localDirectory); + if (empty($filename)) { + return null; + } + $changelog = $this->getRemoteFile($filename, $ref); + if ($changelog === null) { + return null; + } + return \WCPOS\Vendor\Parsedown::instance()->text($changelog); + } + /** + * Guess the name of the changelog file. + * + * @param string $directory + * @return string|null + */ + protected function findChangelogName($directory = null) + { + if (!isset($directory)) { + $directory = $this->localDirectory; + } + if (empty($directory) || !\is_dir($directory) || $directory === '.') { + return null; + } + $possibleNames = array('CHANGES.md', 'CHANGELOG.md', 'changes.md', 'changelog.md'); + $files = \scandir($directory); + $foundNames = \array_intersect($possibleNames, $files); + if (!empty($foundNames)) { + return \reset($foundNames); + } + return null; + } + /** + * Set authentication credentials. + * + * @param $credentials + */ + public function setAuthentication($credentials) + { + $this->credentials = $credentials; + } + public function isAuthenticationEnabled() + { + return !empty($this->credentials); + } + /** + * @param string $url + * @return string + */ + public function signDownloadUrl($url) + { + return $url; + } + /** + * @param string $filterName + */ + public function setHttpFilterName($filterName) + { + $this->httpFilterName = $filterName; + } + /** + * @param string $filterName + */ + public function setStrategyFilterName($filterName) + { + $this->strategyFilterName = $filterName; + } + /** + * @param string $directory + */ + public function setLocalDirectory($directory) + { + if (empty($directory) || !\is_dir($directory) || $directory === '.') { + $this->localDirectory = null; + } else { + $this->localDirectory = $directory; + } + } + /** + * @param string $slug + */ + public function setSlug($slug) + { + $this->slug = $slug; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/BaseChecker.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/BaseChecker.php new file mode 100644 index 0000000..87a4524 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/BaseChecker.php @@ -0,0 +1,27 @@ +[^/]+?)/(?P[^/#?&]+?)/?$@', $path, $matches)) { + $this->username = $matches['username']; + $this->repository = $matches['repository']; + } else { + throw new \InvalidArgumentException('Invalid BitBucket repository URL: "' . $repositoryUrl . '"'); + } + parent::__construct($repositoryUrl, $credentials); + } + protected function getUpdateDetectionStrategies($configBranch) + { + $strategies = array(self::STRATEGY_STABLE_TAG => function () use($configBranch) { + return $this->getStableTag($configBranch); + }); + if ($configBranch === 'master' || $configBranch === 'main') { + $strategies[self::STRATEGY_LATEST_TAG] = array($this, 'getLatestTag'); + } + $strategies[self::STRATEGY_BRANCH] = function () use($configBranch) { + return $this->getBranch($configBranch); + }; + return $strategies; + } + public function getBranch($branchName) + { + $branch = $this->api('/refs/branches/' . $branchName); + if (is_wp_error($branch) || empty($branch)) { + return null; + } + //The "/src/{stuff}/{path}" endpoint doesn't seem to handle branch names that contain slashes. + //If we don't encode the slash, we get a 404. If we encode it as "%2F", we get a 401. + //To avoid issues, if the branch name is not URL-safe, let's use the commit hash instead. + $ref = $branch->name; + if (\urlencode($ref) !== $ref && isset($branch->target->hash)) { + $ref = $branch->target->hash; + } + return new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\Reference(array('name' => $ref, 'updated' => $branch->target->date, 'downloadUrl' => $this->getDownloadUrl($branch->name))); + } + /** + * Get a specific tag. + * + * @param string $tagName + * @return Reference|null + */ + public function getTag($tagName) + { + $tag = $this->api('/refs/tags/' . $tagName); + if (is_wp_error($tag) || empty($tag)) { + return null; + } + return new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\Reference(array('name' => $tag->name, 'version' => \ltrim($tag->name, 'v'), 'updated' => $tag->target->date, 'downloadUrl' => $this->getDownloadUrl($tag->name))); + } + /** + * Get the tag that looks like the highest version number. + * + * @return Reference|null + */ + public function getLatestTag() + { + $tags = $this->api('/refs/tags?sort=-target.date'); + if (!isset($tags, $tags->values) || !\is_array($tags->values)) { + return null; + } + //Filter and sort the list of tags. + $versionTags = $this->sortTagsByVersion($tags->values); + //Return the first result. + if (!empty($versionTags)) { + $tag = $versionTags[0]; + return new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\Reference(array('name' => $tag->name, 'version' => \ltrim($tag->name, 'v'), 'updated' => $tag->target->date, 'downloadUrl' => $this->getDownloadUrl($tag->name))); + } + return null; + } + /** + * Get the tag/ref specified by the "Stable tag" header in the readme.txt of a given branch. + * + * @param string $branch + * @return null|Reference + */ + protected function getStableTag($branch) + { + $remoteReadme = $this->getRemoteReadme($branch); + if (!empty($remoteReadme['stable_tag'])) { + $tag = $remoteReadme['stable_tag']; + //You can explicitly opt out of using tags by setting "Stable tag" to + //"trunk" or the name of the current branch. + if ($tag === $branch || $tag === 'trunk') { + return $this->getBranch($branch); + } + return $this->getTag($tag); + } + return null; + } + /** + * @param string $ref + * @return string + */ + protected function getDownloadUrl($ref) + { + return \sprintf('https://bitbucket.org/%s/%s/get/%s.zip', $this->username, $this->repository, $ref); + } + /** + * Get the contents of a file from a specific branch or tag. + * + * @param string $path File name. + * @param string $ref + * @return null|string Either the contents of the file, or null if the file doesn't exist or there's an error. + */ + public function getRemoteFile($path, $ref = 'master') + { + $response = $this->api('src/' . $ref . '/' . \ltrim($path)); + if (is_wp_error($response) || !\is_string($response)) { + return null; + } + return $response; + } + /** + * Get the timestamp of the latest commit that changed the specified branch or tag. + * + * @param string $ref Reference name (e.g. branch or tag). + * @return string|null + */ + public function getLatestCommitTime($ref) + { + $response = $this->api('commits/' . $ref); + if (isset($response->values, $response->values[0], $response->values[0]->date)) { + return $response->values[0]->date; + } + return null; + } + /** + * Perform a BitBucket API 2.0 request. + * + * @param string $url + * @param string $version + * @return mixed|\WP_Error + */ + public function api($url, $version = '2.0') + { + $url = \ltrim($url, '/'); + $isSrcResource = \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Utils::startsWith($url, 'src/'); + $url = \implode('/', array('https://api.bitbucket.org', $version, 'repositories', $this->username, $this->repository, $url)); + $baseUrl = $url; + if ($this->oauth) { + $url = $this->oauth->sign($url, 'GET'); + } + $options = array('timeout' => wp_doing_cron() ? 10 : 3); + if (!empty($this->httpFilterName)) { + $options = apply_filters($this->httpFilterName, $options); + } + $response = wp_remote_get($url, $options); + if (is_wp_error($response)) { + do_action('puc_api_error', $response, null, $url, $this->slug); + return $response; + } + $code = wp_remote_retrieve_response_code($response); + $body = wp_remote_retrieve_body($response); + if ($code === 200) { + if ($isSrcResource) { + //Most responses are JSON-encoded, but src resources just + //return raw file contents. + $document = $body; + } else { + $document = \json_decode($body); + } + return $document; + } + $error = new \WCPOS\Vendor\WP_Error('puc-bitbucket-http-error', \sprintf('BitBucket API error. Base URL: "%s", HTTP status code: %d.', $baseUrl, $code)); + do_action('puc_api_error', $error, $response, $url, $this->slug); + return $error; + } + /** + * @param array $credentials + */ + public function setAuthentication($credentials) + { + parent::setAuthentication($credentials); + if (!empty($credentials) && !empty($credentials['consumer_key'])) { + $this->oauth = new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\OAuthSignature($credentials['consumer_key'], $credentials['consumer_secret']); + } else { + $this->oauth = null; + } + } + public function signDownloadUrl($url) + { + //Add authentication data to download URLs. Since OAuth signatures incorporate + //timestamps, we have to do this immediately before inserting the update. Otherwise, + //authentication could fail due to a stale timestamp. + if ($this->oauth) { + $url = $this->oauth->sign($url); + } + return $url; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/GitHubApi.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/GitHubApi.php new file mode 100644 index 0000000..5155894 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/GitHubApi.php @@ -0,0 +1,387 @@ +[^/]+?)/(?P[^/#?&]+?)/?$@', $path, $matches)) { + $this->userName = $matches['username']; + $this->repositoryName = $matches['repository']; + } else { + throw new \InvalidArgumentException('Invalid GitHub repository URL: "' . $repositoryUrl . '"'); + } + parent::__construct($repositoryUrl, $accessToken); + } + /** + * Get the latest release from GitHub. + * + * @return Reference|null + */ + public function getLatestRelease() + { + //The "latest release" endpoint returns one release and always skips pre-releases, + //so we can only use it if that's compatible with the current filter settings. + if ($this->shouldSkipPreReleases() && ($this->releaseFilterMaxReleases === 1 || !$this->hasCustomReleaseFilter())) { + //Just get the latest release. + $release = $this->api('/repos/:user/:repo/releases/latest'); + if (is_wp_error($release) || !\is_object($release) || !isset($release->tag_name)) { + return null; + } + $foundReleases = array($release); + } else { + //Get a list of the most recent releases. + $foundReleases = $this->api('/repos/:user/:repo/releases', array('per_page' => $this->releaseFilterMaxReleases)); + if (is_wp_error($foundReleases) || !\is_array($foundReleases)) { + return null; + } + } + foreach ($foundReleases as $release) { + //Always skip drafts. + if (isset($release->draft) && !empty($release->draft)) { + continue; + } + //Skip pre-releases unless specifically included. + if ($this->shouldSkipPreReleases() && isset($release->prerelease) && !empty($release->prerelease)) { + continue; + } + $versionNumber = \ltrim($release->tag_name, 'v'); + //Remove the "v" prefix from "v1.2.3". + //Custom release filtering. + if (!$this->matchesCustomReleaseFilter($versionNumber, $release)) { + continue; + } + $reference = new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\Reference(array('name' => $release->tag_name, 'version' => $versionNumber, 'downloadUrl' => $release->zipball_url, 'updated' => $release->created_at, 'apiResponse' => $release)); + if (isset($release->assets[0])) { + $reference->downloadCount = $release->assets[0]->download_count; + } + if ($this->releaseAssetsEnabled) { + //Use the first release asset that matches the specified regular expression. + if (isset($release->assets, $release->assets[0])) { + $matchingAssets = \array_values(\array_filter($release->assets, array($this, 'matchesAssetFilter'))); + } else { + $matchingAssets = array(); + } + if (!empty($matchingAssets)) { + if ($this->isAuthenticationEnabled()) { + /** + * Keep in mind that we'll need to add an "Accept" header to download this asset. + * + * @see setUpdateDownloadHeaders() + */ + $reference->downloadUrl = $matchingAssets[0]->url; + } else { + //It seems that browser_download_url only works for public repositories. + //Using an access_token doesn't help. Maybe OAuth would work? + $reference->downloadUrl = $matchingAssets[0]->browser_download_url; + } + $reference->downloadCount = $matchingAssets[0]->download_count; + } else { + if ($this->releaseAssetPreference === \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\Api::REQUIRE_RELEASE_ASSETS) { + //None of the assets match the filter, and we're not allowed + //to fall back to the auto-generated source ZIP. + return null; + } + } + } + if (!empty($release->body)) { + $reference->changelog = \WCPOS\Vendor\Parsedown::instance()->text($release->body); + } + return $reference; + } + return null; + } + /** + * Get the tag that looks like the highest version number. + * + * @return Reference|null + */ + public function getLatestTag() + { + $tags = $this->api('/repos/:user/:repo/tags'); + if (is_wp_error($tags) || !\is_array($tags)) { + return null; + } + $versionTags = $this->sortTagsByVersion($tags); + if (empty($versionTags)) { + return null; + } + $tag = $versionTags[0]; + return new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\Reference(array('name' => $tag->name, 'version' => \ltrim($tag->name, 'v'), 'downloadUrl' => $tag->zipball_url, 'apiResponse' => $tag)); + } + /** + * Get a branch by name. + * + * @param string $branchName + * @return null|Reference + */ + public function getBranch($branchName) + { + $branch = $this->api('/repos/:user/:repo/branches/' . $branchName); + if (is_wp_error($branch) || empty($branch)) { + return null; + } + $reference = new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\Reference(array('name' => $branch->name, 'downloadUrl' => $this->buildArchiveDownloadUrl($branch->name), 'apiResponse' => $branch)); + if (isset($branch->commit, $branch->commit->commit, $branch->commit->commit->author->date)) { + $reference->updated = $branch->commit->commit->author->date; + } + return $reference; + } + /** + * Get the latest commit that changed the specified file. + * + * @param string $filename + * @param string $ref Reference name (e.g. branch or tag). + * @return \StdClass|null + */ + public function getLatestCommit($filename, $ref = 'master') + { + $commits = $this->api('/repos/:user/:repo/commits', array('path' => $filename, 'sha' => $ref)); + if (!is_wp_error($commits) && isset($commits[0])) { + return $commits[0]; + } + return null; + } + /** + * Get the timestamp of the latest commit that changed the specified branch or tag. + * + * @param string $ref Reference name (e.g. branch or tag). + * @return string|null + */ + public function getLatestCommitTime($ref) + { + $commits = $this->api('/repos/:user/:repo/commits', array('sha' => $ref)); + if (!is_wp_error($commits) && isset($commits[0])) { + return $commits[0]->commit->author->date; + } + return null; + } + /** + * Perform a GitHub API request. + * + * @param string $url + * @param array $queryParams + * @return mixed|\WP_Error + */ + protected function api($url, $queryParams = array()) + { + $baseUrl = $url; + $url = $this->buildApiUrl($url, $queryParams); + $options = array('timeout' => wp_doing_cron() ? 10 : 3); + if ($this->isAuthenticationEnabled()) { + $options['headers'] = array('Authorization' => $this->getAuthorizationHeader()); + } + if (!empty($this->httpFilterName)) { + $options = apply_filters($this->httpFilterName, $options); + } + $response = wp_remote_get($url, $options); + if (is_wp_error($response)) { + do_action('puc_api_error', $response, null, $url, $this->slug); + return $response; + } + $code = wp_remote_retrieve_response_code($response); + $body = wp_remote_retrieve_body($response); + if ($code === 200) { + $document = \json_decode($body); + return $document; + } + $error = new \WCPOS\Vendor\WP_Error('puc-github-http-error', \sprintf('GitHub API error. Base URL: "%s", HTTP status code: %d.', $baseUrl, $code)); + do_action('puc_api_error', $error, $response, $url, $this->slug); + return $error; + } + /** + * Build a fully qualified URL for an API request. + * + * @param string $url + * @param array $queryParams + * @return string + */ + protected function buildApiUrl($url, $queryParams) + { + $variables = array('user' => $this->userName, 'repo' => $this->repositoryName); + foreach ($variables as $name => $value) { + $url = \str_replace('/:' . $name, '/' . \urlencode($value), $url); + } + $url = 'https://api.github.com' . $url; + if (!empty($queryParams)) { + $url = add_query_arg($queryParams, $url); + } + return $url; + } + /** + * Get the contents of a file from a specific branch or tag. + * + * @param string $path File name. + * @param string $ref + * @return null|string Either the contents of the file, or null if the file doesn't exist or there's an error. + */ + public function getRemoteFile($path, $ref = 'master') + { + $apiUrl = '/repos/:user/:repo/contents/' . $path; + $response = $this->api($apiUrl, array('ref' => $ref)); + if (is_wp_error($response) || !isset($response->content) || $response->encoding !== 'base64') { + return null; + } + return \base64_decode($response->content); + } + /** + * Generate a URL to download a ZIP archive of the specified branch/tag/etc. + * + * @param string $ref + * @return string + */ + public function buildArchiveDownloadUrl($ref = 'master') + { + $url = \sprintf('https://api.github.com/repos/%1$s/%2$s/zipball/%3$s', \urlencode($this->userName), \urlencode($this->repositoryName), \urlencode($ref)); + return $url; + } + /** + * Get a specific tag. + * + * @param string $tagName + * @return void + */ + public function getTag($tagName) + { + //The current GitHub update checker doesn't use getTag, so I didn't bother to implement it. + throw new \LogicException('The ' . __METHOD__ . ' method is not implemented and should not be used.'); + } + public function setAuthentication($credentials) + { + parent::setAuthentication($credentials); + $this->accessToken = \is_string($credentials) ? $credentials : null; + //Optimization: Instead of filtering all HTTP requests, let's do it only when + //WordPress is about to download an update. + add_filter('upgrader_pre_download', array($this, 'addHttpRequestFilter'), 10, 1); + //WP 3.7+ + } + protected function getUpdateDetectionStrategies($configBranch) + { + $strategies = array(); + if ($configBranch === 'master' || $configBranch === 'main') { + //Use the latest release. + $strategies[self::STRATEGY_LATEST_RELEASE] = array($this, 'getLatestRelease'); + //Failing that, use the tag with the highest version number. + $strategies[self::STRATEGY_LATEST_TAG] = array($this, 'getLatestTag'); + } + //Alternatively, just use the branch itself. + $strategies[self::STRATEGY_BRANCH] = function () use($configBranch) { + return $this->getBranch($configBranch); + }; + return $strategies; + } + /** + * Get the unchanging part of a release asset URL. Used to identify download attempts. + * + * @return string + */ + protected function getAssetApiBaseUrl() + { + return \sprintf('//api.github.com/repos/%1$s/%2$s/releases/assets/', $this->userName, $this->repositoryName); + } + protected function getFilterableAssetName($releaseAsset) + { + if (isset($releaseAsset->name)) { + return $releaseAsset->name; + } + return null; + } + /** + * @param bool $result + * @return bool + * @internal + */ + public function addHttpRequestFilter($result) + { + if (!$this->downloadFilterAdded && $this->isAuthenticationEnabled()) { + //phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.http_request_args -- The callback doesn't change the timeout. + add_filter('http_request_args', array($this, 'setUpdateDownloadHeaders'), 10, 2); + add_action('requests-requests.before_redirect', array($this, 'removeAuthHeaderFromRedirects'), 10, 4); + $this->downloadFilterAdded = \true; + } + return $result; + } + /** + * Set the HTTP headers that are necessary to download updates from private repositories. + * + * See GitHub docs: + * + * @link https://developer.github.com/v3/repos/releases/#get-a-single-release-asset + * @link https://developer.github.com/v3/auth/#basic-authentication + * + * @internal + * @param array $requestArgs + * @param string $url + * @return array + */ + public function setUpdateDownloadHeaders($requestArgs, $url = '') + { + //Is WordPress trying to download one of our release assets? + if ($this->releaseAssetsEnabled && \strpos($url, $this->getAssetApiBaseUrl()) !== \false) { + $requestArgs['headers']['Accept'] = 'application/octet-stream'; + } + //Use Basic authentication, but only if the download is from our repository. + $repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', array()); + if ($this->isAuthenticationEnabled() && \strpos($url, $repoApiBaseUrl) === 0) { + $requestArgs['headers']['Authorization'] = $this->getAuthorizationHeader(); + } + return $requestArgs; + } + /** + * When following a redirect, the Requests library will automatically forward + * the authorization header to other hosts. We don't want that because it breaks + * AWS downloads and can leak authorization information. + * + * @param string $location + * @param array $headers + * @internal + */ + public function removeAuthHeaderFromRedirects(&$location, &$headers) + { + $repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', array()); + if (\strpos($location, $repoApiBaseUrl) === 0) { + return; + //This request is going to GitHub, so it's fine. + } + //Remove the header. + if (isset($headers['Authorization'])) { + unset($headers['Authorization']); + } + } + /** + * Generate the value of the "Authorization" header. + * + * @return string + */ + protected function getAuthorizationHeader() + { + return 'Basic ' . \base64_encode($this->userName . ':' . $this->accessToken); + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/GitLabApi.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/GitLabApi.php new file mode 100644 index 0000000..7f2b353 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/GitLabApi.php @@ -0,0 +1,333 @@ +repositoryHost = wp_parse_url($repositoryUrl, \PHP_URL_HOST) . $port; + if ($this->repositoryHost !== 'gitlab.com') { + $this->repositoryProtocol = wp_parse_url($repositoryUrl, \PHP_URL_SCHEME); + } + //Find the repository information + $path = wp_parse_url($repositoryUrl, \PHP_URL_PATH); + if (\preg_match('@^/?(?P[^/]+?)/(?P[^/#?&]+?)/?$@', $path, $matches)) { + $this->userName = $matches['username']; + $this->repositoryName = $matches['repository']; + } elseif ($this->repositoryHost === 'gitlab.com') { + //This is probably a repository in a subgroup, e.g. "/organization/category/repo". + $parts = \explode('/', \trim($path, '/')); + if (\count($parts) < 3) { + throw new \InvalidArgumentException('Invalid GitLab.com repository URL: "' . $repositoryUrl . '"'); + } + $lastPart = \array_pop($parts); + $this->userName = \implode('/', $parts); + $this->repositoryName = $lastPart; + } else { + //There could be subgroups in the URL: gitlab.domain.com/group/subgroup/subgroup2/repository + if ($subgroup !== null) { + $path = \str_replace(trailingslashit($subgroup), '', $path); + } + //This is not a traditional url, it could be gitlab is in a deeper subdirectory. + //Get the path segments. + $segments = \explode('/', untrailingslashit(\ltrim($path, '/'))); + //We need at least /user-name/repository-name/ + if (\count($segments) < 2) { + throw new \InvalidArgumentException('Invalid GitLab repository URL: "' . $repositoryUrl . '"'); + } + //Get the username and repository name. + $usernameRepo = \array_splice($segments, -2, 2); + $this->userName = $usernameRepo[0]; + $this->repositoryName = $usernameRepo[1]; + //Append the remaining segments to the host if there are segments left. + if (\count($segments) > 0) { + $this->repositoryHost = trailingslashit($this->repositoryHost) . \implode('/', $segments); + } + //Add subgroups to username. + if ($subgroup !== null) { + $this->userName = $usernameRepo[0] . '/' . untrailingslashit($subgroup); + } + } + parent::__construct($repositoryUrl, $accessToken); + } + /** + * Get the latest release from GitLab. + * + * @return Reference|null + */ + public function getLatestRelease() + { + $releases = $this->api('/:id/releases', array('per_page' => $this->releaseFilterMaxReleases)); + if (is_wp_error($releases) || empty($releases) || !\is_array($releases)) { + return null; + } + foreach ($releases as $release) { + if (!\is_object($release) || !isset($release->tag_name) || !empty($release->upcoming_release) && $this->shouldSkipPreReleases()) { + continue; + } + $versionNumber = \ltrim($release->tag_name, 'v'); + //Remove the "v" prefix from "v1.2.3". + //Apply custom filters. + if (!$this->matchesCustomReleaseFilter($versionNumber, $release)) { + continue; + } + $downloadUrl = $this->findReleaseDownloadUrl($release); + if (empty($downloadUrl)) { + //The latest release doesn't have valid download URL. + return null; + } + if (!empty($this->accessToken)) { + $downloadUrl = add_query_arg('private_token', $this->accessToken, $downloadUrl); + } + return new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\Reference(array('name' => $release->tag_name, 'version' => $versionNumber, 'downloadUrl' => $downloadUrl, 'updated' => $release->released_at, 'apiResponse' => $release)); + } + return null; + } + /** + * @param object $release + * @return string|null + */ + protected function findReleaseDownloadUrl($release) + { + if ($this->releaseAssetsEnabled) { + if (isset($release->assets, $release->assets->links)) { + //Use the first asset link where the URL matches the filter. + foreach ($release->assets->links as $link) { + if ($this->matchesAssetFilter($link)) { + return $link->url; + } + } + } + if ($this->releaseAssetPreference === \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\Api::REQUIRE_RELEASE_ASSETS) { + //Falling back to source archives is not allowed, so give up. + return null; + } + } + //Use the first source code archive that's in ZIP format. + foreach ($release->assets->sources as $source) { + if (isset($source->format) && $source->format === 'zip') { + return $source->url; + } + } + return null; + } + /** + * Get the tag that looks like the highest version number. + * + * @return Reference|null + */ + public function getLatestTag() + { + $tags = $this->api('/:id/repository/tags'); + if (is_wp_error($tags) || empty($tags) || !\is_array($tags)) { + return null; + } + $versionTags = $this->sortTagsByVersion($tags); + if (empty($versionTags)) { + return null; + } + $tag = $versionTags[0]; + return new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\Reference(array('name' => $tag->name, 'version' => \ltrim($tag->name, 'v'), 'downloadUrl' => $this->buildArchiveDownloadUrl($tag->name), 'apiResponse' => $tag)); + } + /** + * Get a branch by name. + * + * @param string $branchName + * @return null|Reference + */ + public function getBranch($branchName) + { + $branch = $this->api('/:id/repository/branches/' . $branchName); + if (is_wp_error($branch) || empty($branch)) { + return null; + } + $reference = new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\Reference(array('name' => $branch->name, 'downloadUrl' => $this->buildArchiveDownloadUrl($branch->name), 'apiResponse' => $branch)); + if (isset($branch->commit, $branch->commit->committed_date)) { + $reference->updated = $branch->commit->committed_date; + } + return $reference; + } + /** + * Get the timestamp of the latest commit that changed the specified branch or tag. + * + * @param string $ref Reference name (e.g. branch or tag). + * @return string|null + */ + public function getLatestCommitTime($ref) + { + $commits = $this->api('/:id/repository/commits/', array('ref_name' => $ref)); + if (is_wp_error($commits) || !\is_array($commits) || !isset($commits[0])) { + return null; + } + return $commits[0]->committed_date; + } + /** + * Perform a GitLab API request. + * + * @param string $url + * @param array $queryParams + * @return mixed|\WP_Error + */ + protected function api($url, $queryParams = array()) + { + $baseUrl = $url; + $url = $this->buildApiUrl($url, $queryParams); + $options = array('timeout' => wp_doing_cron() ? 10 : 3); + if (!empty($this->httpFilterName)) { + $options = apply_filters($this->httpFilterName, $options); + } + $response = wp_remote_get($url, $options); + if (is_wp_error($response)) { + do_action('puc_api_error', $response, null, $url, $this->slug); + return $response; + } + $code = wp_remote_retrieve_response_code($response); + $body = wp_remote_retrieve_body($response); + if ($code === 200) { + return \json_decode($body); + } + $error = new \WCPOS\Vendor\WP_Error('puc-gitlab-http-error', \sprintf('GitLab API error. URL: "%s", HTTP status code: %d.', $baseUrl, $code)); + do_action('puc_api_error', $error, $response, $url, $this->slug); + return $error; + } + /** + * Build a fully qualified URL for an API request. + * + * @param string $url + * @param array $queryParams + * @return string + */ + protected function buildApiUrl($url, $queryParams) + { + $variables = array('user' => $this->userName, 'repo' => $this->repositoryName, 'id' => $this->userName . '/' . $this->repositoryName); + foreach ($variables as $name => $value) { + $url = \str_replace("/:{$name}", '/' . \urlencode($value), $url); + } + $url = \substr($url, 1); + $url = \sprintf('%1$s://%2$s/api/v4/projects/%3$s', $this->repositoryProtocol, $this->repositoryHost, $url); + if (!empty($this->accessToken)) { + $queryParams['private_token'] = $this->accessToken; + } + if (!empty($queryParams)) { + $url = add_query_arg($queryParams, $url); + } + return $url; + } + /** + * Get the contents of a file from a specific branch or tag. + * + * @param string $path File name. + * @param string $ref + * @return null|string Either the contents of the file, or null if the file doesn't exist or there's an error. + */ + public function getRemoteFile($path, $ref = 'master') + { + $response = $this->api('/:id/repository/files/' . $path, array('ref' => $ref)); + if (is_wp_error($response) || !isset($response->content) || $response->encoding !== 'base64') { + return null; + } + return \base64_decode($response->content); + } + /** + * Generate a URL to download a ZIP archive of the specified branch/tag/etc. + * + * @param string $ref + * @return string + */ + public function buildArchiveDownloadUrl($ref = 'master') + { + $url = \sprintf('%1$s://%2$s/api/v4/projects/%3$s/repository/archive.zip', $this->repositoryProtocol, $this->repositoryHost, \urlencode($this->userName . '/' . $this->repositoryName)); + $url = add_query_arg('sha', \urlencode($ref), $url); + if (!empty($this->accessToken)) { + $url = add_query_arg('private_token', $this->accessToken, $url); + } + return $url; + } + /** + * Get a specific tag. + * + * @param string $tagName + * @return void + */ + public function getTag($tagName) + { + throw new \LogicException('The ' . __METHOD__ . ' method is not implemented and should not be used.'); + } + protected function getUpdateDetectionStrategies($configBranch) + { + $strategies = array(); + if ($configBranch === 'main' || $configBranch === 'master') { + $strategies[self::STRATEGY_LATEST_RELEASE] = array($this, 'getLatestRelease'); + $strategies[self::STRATEGY_LATEST_TAG] = array($this, 'getLatestTag'); + } + $strategies[self::STRATEGY_BRANCH] = function () use($configBranch) { + return $this->getBranch($configBranch); + }; + return $strategies; + } + public function setAuthentication($credentials) + { + parent::setAuthentication($credentials); + $this->accessToken = \is_string($credentials) ? $credentials : null; + } + /** + * Use release assets that link to GitLab generic packages (e.g. .zip files) + * instead of automatically generated source archives. + * + * This is included for backwards compatibility with older versions of PUC. + * + * @return void + * @deprecated Use enableReleaseAssets() instead. + * @noinspection PhpUnused -- Public API + */ + public function enableReleasePackages() + { + $this->enableReleaseAssets( + /** @lang RegExp */ + '/\\.zip($|[?&#])/i', + \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\Api::REQUIRE_RELEASE_ASSETS + ); + } + protected function getFilterableAssetName($releaseAsset) + { + if (isset($releaseAsset->url)) { + return $releaseAsset->url; + } + return null; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/PluginUpdateChecker.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/PluginUpdateChecker.php new file mode 100644 index 0000000..7231f8b --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/PluginUpdateChecker.php @@ -0,0 +1,204 @@ +api = $api; + parent::__construct($api->getRepositoryUrl(), $pluginFile, $slug, $checkPeriod, $optionName, $muPluginFile); + $this->api->setHttpFilterName($this->getUniqueName('request_info_options')); + $this->api->setStrategyFilterName($this->getUniqueName('vcs_update_detection_strategies')); + $this->api->setSlug($this->slug); + } + public function requestInfo($unusedParameter = null) + { + //We have to make several remote API requests to gather all the necessary info + //which can take a while on slow networks. + if (\function_exists('set_time_limit')) { + @\set_time_limit(60); + } + $api = $this->api; + $api->setLocalDirectory($this->package->getAbsoluteDirectoryPath()); + $info = new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Plugin\PluginInfo(); + $info->filename = $this->pluginFile; + $info->slug = $this->slug; + $this->setInfoFromHeader($this->package->getPluginHeader(), $info); + $this->setIconsFromLocalAssets($info); + $this->setBannersFromLocalAssets($info); + //Pick a branch or tag. + $updateSource = $api->chooseReference($this->branch); + if ($updateSource) { + $ref = $updateSource->name; + $info->version = $updateSource->version; + $info->last_updated = $updateSource->updated; + $info->download_url = $updateSource->downloadUrl; + if (!empty($updateSource->changelog)) { + $info->sections['changelog'] = $updateSource->changelog; + } + if (isset($updateSource->downloadCount)) { + $info->downloaded = $updateSource->downloadCount; + } + } else { + //There's probably a network problem or an authentication error. + do_action('puc_api_error', new \WCPOS\Vendor\WP_Error('puc-no-update-source', 'Could not retrieve version information from the repository. ' . 'This usually means that the update checker either can\'t connect ' . 'to the repository or it\'s configured incorrectly.'), null, null, $this->slug); + return null; + } + //Get headers from the main plugin file in this branch/tag. Its "Version" header and other metadata + //are what the WordPress install will actually see after upgrading, so they take precedence over releases/tags. + $mainPluginFile = \basename($this->pluginFile); + $remotePlugin = $api->getRemoteFile($mainPluginFile, $ref); + if (!empty($remotePlugin)) { + $remoteHeader = $this->package->getFileHeader($remotePlugin); + $this->setInfoFromHeader($remoteHeader, $info); + } + //Try parsing readme.txt. If it's formatted according to WordPress.org standards, it will contain + //a lot of useful information like the required/tested WP version, changelog, and so on. + if ($this->readmeTxtExistsLocally()) { + $this->setInfoFromRemoteReadme($ref, $info); + } + //The changelog might be in a separate file. + if (empty($info->sections['changelog'])) { + $info->sections['changelog'] = $api->getRemoteChangelog($ref, $this->package->getAbsoluteDirectoryPath()); + if (empty($info->sections['changelog'])) { + $info->sections['changelog'] = __('There is no changelog available.', 'plugin-update-checker'); + } + } + if (empty($info->last_updated)) { + //Fetch the latest commit that changed the tag or branch and use it as the "last_updated" date. + $latestCommitTime = $api->getLatestCommitTime($ref); + if ($latestCommitTime !== null) { + $info->last_updated = $latestCommitTime; + } + } + $info = apply_filters($this->getUniqueName('request_info_result'), $info, null); + return $info; + } + /** + * Check if the currently installed version has a readme.txt file. + * + * @return bool + */ + protected function readmeTxtExistsLocally() + { + return $this->package->fileExists($this->api->getLocalReadmeName()); + } + /** + * Copy plugin metadata from a file header to a Plugin Info object. + * + * @param array $fileHeader + * @param Plugin\PluginInfo $pluginInfo + */ + protected function setInfoFromHeader($fileHeader, $pluginInfo) + { + $headerToPropertyMap = array('Version' => 'version', 'Name' => 'name', 'PluginURI' => 'homepage', 'Author' => 'author', 'AuthorName' => 'author', 'AuthorURI' => 'author_homepage', 'Requires WP' => 'requires', 'Tested WP' => 'tested', 'Requires at least' => 'requires', 'Tested up to' => 'tested', 'Requires PHP' => 'requires_php'); + foreach ($headerToPropertyMap as $headerName => $property) { + if (isset($fileHeader[$headerName]) && !empty($fileHeader[$headerName])) { + $pluginInfo->{$property} = $fileHeader[$headerName]; + } + } + if (!empty($fileHeader['Description'])) { + $pluginInfo->sections['description'] = $fileHeader['Description']; + } + } + /** + * Copy plugin metadata from the remote readme.txt file. + * + * @param string $ref GitHub tag or branch where to look for the readme. + * @param Plugin\PluginInfo $pluginInfo + */ + protected function setInfoFromRemoteReadme($ref, $pluginInfo) + { + $readme = $this->api->getRemoteReadme($ref); + if (empty($readme)) { + return; + } + if (isset($readme['sections'])) { + $pluginInfo->sections = \array_merge($pluginInfo->sections, $readme['sections']); + } + if (!empty($readme['tested_up_to'])) { + $pluginInfo->tested = $readme['tested_up_to']; + } + if (!empty($readme['requires_at_least'])) { + $pluginInfo->requires = $readme['requires_at_least']; + } + if (!empty($readme['requires_php'])) { + $pluginInfo->requires_php = $readme['requires_php']; + } + if (isset($readme['upgrade_notice'], $readme['upgrade_notice'][$pluginInfo->version])) { + $pluginInfo->upgrade_notice = $readme['upgrade_notice'][$pluginInfo->version]; + } + } + /** + * Add icons from the currently installed version to a Plugin Info object. + * + * The icons should be in a subdirectory named "assets". Supported image formats + * and file names are described here: + * @link https://developer.wordpress.org/plugins/wordpress-org/plugin-assets/#plugin-icons + * + * @param Plugin\PluginInfo $pluginInfo + */ + protected function setIconsFromLocalAssets($pluginInfo) + { + $icons = $this->getLocalAssetUrls(array('icon.svg' => 'svg', 'icon-256x256.png' => '2x', 'icon-256x256.jpg' => '2x', 'icon-128x128.png' => '1x', 'icon-128x128.jpg' => '1x')); + if (!empty($icons)) { + //The "default" key seems to be used only as last-resort fallback in WP core (5.8/5.9), + //but we'll set it anyway in case some code somewhere needs it. + \reset($icons); + $firstKey = \key($icons); + $icons['default'] = $icons[$firstKey]; + $pluginInfo->icons = $icons; + } + } + /** + * Add banners from the currently installed version to a Plugin Info object. + * + * The banners should be in a subdirectory named "assets". Supported image formats + * and file names are described here: + * @link https://developer.wordpress.org/plugins/wordpress-org/plugin-assets/#plugin-headers + * + * @param Plugin\PluginInfo $pluginInfo + */ + protected function setBannersFromLocalAssets($pluginInfo) + { + $banners = $this->getLocalAssetUrls(array('banner-772x250.png' => 'high', 'banner-772x250.jpg' => 'high', 'banner-1544x500.png' => 'low', 'banner-1544x500.jpg' => 'low')); + if (!empty($banners)) { + $pluginInfo->banners = $banners; + } + } + /** + * @param array $filesToKeys + * @return array + */ + protected function getLocalAssetUrls($filesToKeys) + { + $assetDirectory = $this->package->getAbsoluteDirectoryPath() . \DIRECTORY_SEPARATOR . 'assets'; + if (!\is_dir($assetDirectory)) { + return array(); + } + $assetBaseUrl = trailingslashit(plugins_url('', $assetDirectory . '/imaginary.file')); + $foundAssets = array(); + foreach ($filesToKeys as $fileName => $key) { + $fullBannerPath = $assetDirectory . \DIRECTORY_SEPARATOR . $fileName; + if (!isset($icons[$key]) && \is_file($fullBannerPath)) { + $foundAssets[$key] = $assetBaseUrl . $fileName; + } + } + return $foundAssets; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/Reference.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/Reference.php new file mode 100644 index 0000000..8f580e4 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/Reference.php @@ -0,0 +1,50 @@ +properties = $properties; + } + /** + * @param string $name + * @return mixed|null + */ + public function __get($name) + { + return \array_key_exists($name, $this->properties) ? $this->properties[$name] : null; + } + /** + * @param string $name + * @param mixed $value + */ + public function __set($name, $value) + { + $this->properties[$name] = $value; + } + /** + * @param string $name + * @return bool + */ + public function __isset($name) + { + return isset($this->properties[$name]); + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/ReleaseAssetSupport.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/ReleaseAssetSupport.php new file mode 100644 index 0000000..96087e1 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/ReleaseAssetSupport.php @@ -0,0 +1,78 @@ +releaseAssetsEnabled = \true; + $this->assetFilterRegex = $nameRegex; + $this->releaseAssetPreference = $preference; + } + /** + * Disable release assets. + * + * @return void + * @noinspection PhpUnused -- Public API + */ + public function disableReleaseAssets() + { + $this->releaseAssetsEnabled = \false; + $this->assetFilterRegex = null; + } + /** + * Does the specified asset match the name regex? + * + * @param mixed $releaseAsset Data type and structure depend on the host/API. + * @return bool + */ + protected function matchesAssetFilter($releaseAsset) + { + if ($this->assetFilterRegex === null) { + //The default is to accept all assets. + return \true; + } + $name = $this->getFilterableAssetName($releaseAsset); + if (!\is_string($name)) { + return \false; + } + return (bool) \preg_match($this->assetFilterRegex, $releaseAsset->name); + } + /** + * Get the part of asset data that will be checked against the filter regex. + * + * @param mixed $releaseAsset + * @return string|null + */ + protected abstract function getFilterableAssetName($releaseAsset); + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/ReleaseFilteringFeature.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/ReleaseFilteringFeature.php new file mode 100644 index 0000000..e13546c --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/ReleaseFilteringFeature.php @@ -0,0 +1,91 @@ + 100) { + throw new \InvalidArgumentException(\sprintf('The max number of releases is too high (%d). It must be 100 or less.', $maxReleases)); + } else { + if ($maxReleases < 1) { + throw new \InvalidArgumentException(\sprintf('The max number of releases is too low (%d). It must be at least 1.', $maxReleases)); + } + } + $this->releaseFilterCallback = $callback; + $this->releaseFilterByType = $releaseTypes; + $this->releaseFilterMaxReleases = $maxReleases; + return $this; + } + /** + * Filter releases by their version number. + * + * @param string $regex A regular expression. The release version number must match this regex. + * @param int $releaseTypes + * @param int $maxReleasesToExamine + * @return $this + * @noinspection PhpUnused -- Public API + */ + public function setReleaseVersionFilter($regex, $releaseTypes = \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\Api::RELEASE_FILTER_SKIP_PRERELEASE, $maxReleasesToExamine = 20) + { + return $this->setReleaseFilter(function ($versionNumber) use($regex) { + return \preg_match($regex, $versionNumber) === 1; + }, $releaseTypes, $maxReleasesToExamine); + } + /** + * @param string $versionNumber The detected release version number. + * @param object $releaseObject Varies depending on the host/API. + * @return bool + */ + protected function matchesCustomReleaseFilter($versionNumber, $releaseObject) + { + if (!\is_callable($this->releaseFilterCallback)) { + return \true; + //No custom filter. + } + return \call_user_func($this->releaseFilterCallback, $versionNumber, $releaseObject); + } + /** + * @return bool + */ + protected function shouldSkipPreReleases() + { + //Maybe this could be a bitfield in the future, if we need to support + //more release types. + return $this->releaseFilterByType !== \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\Api::RELEASE_FILTER_ALL; + } + /** + * @return bool + */ + protected function hasCustomReleaseFilter() + { + return isset($this->releaseFilterCallback) && \is_callable($this->releaseFilterCallback); + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/ThemeUpdateChecker.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/ThemeUpdateChecker.php new file mode 100644 index 0000000..8358488 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/ThemeUpdateChecker.php @@ -0,0 +1,57 @@ +api = $api; + parent::__construct($api->getRepositoryUrl(), $stylesheet, $customSlug, $checkPeriod, $optionName); + $this->api->setHttpFilterName($this->getUniqueName('request_update_options')); + $this->api->setStrategyFilterName($this->getUniqueName('vcs_update_detection_strategies')); + $this->api->setSlug($this->slug); + } + public function requestUpdate() + { + $api = $this->api; + $api->setLocalDirectory($this->package->getAbsoluteDirectoryPath()); + $update = new \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Theme\Update(); + $update->slug = $this->slug; + //Figure out which reference (tag or branch) we'll use to get the latest version of the theme. + $updateSource = $api->chooseReference($this->branch); + if ($updateSource) { + $ref = $updateSource->name; + $update->download_url = $updateSource->downloadUrl; + } else { + do_action('puc_api_error', new \WCPOS\Vendor\WP_Error('puc-no-update-source', 'Could not retrieve version information from the repository. ' . 'This usually means that the update checker either can\'t connect ' . 'to the repository or it\'s configured incorrectly.'), null, null, $this->slug); + $ref = $this->branch; + } + //Get headers from the main stylesheet in this branch/tag. Its "Version" header and other metadata + //are what the WordPress install will actually see after upgrading, so they take precedence over releases/tags. + $remoteHeader = $this->package->getFileHeader($api->getRemoteFile('style.css', $ref)); + $update->version = \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Utils::findNotEmpty(array($remoteHeader['Version'], \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Utils::get($updateSource, 'version'))); + //The details URL defaults to the Theme URI header or the repository URL. + $update->details_url = \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Utils::findNotEmpty(array($remoteHeader['ThemeURI'], $this->package->getHeaderValue('ThemeURI'), $this->metadataUrl)); + if (empty($update->version)) { + //It looks like we didn't find a valid update after all. + $update = null; + } + $update = $this->filterUpdateResult($update); + return $update; + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/VcsCheckerMethods.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/VcsCheckerMethods.php new file mode 100644 index 0000000..643db00 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/Puc/v5p2/Vcs/VcsCheckerMethods.php @@ -0,0 +1,55 @@ +branch = $branch; + return $this; + } + /** + * Set authentication credentials. + * + * @param array|string $credentials + * @return $this + */ + public function setAuthentication($credentials) + { + $this->api->setAuthentication($credentials); + return $this; + } + /** + * @return Api + */ + public function getVcsApi() + { + return $this->api; + } + public function getUpdate() + { + $update = parent::getUpdate(); + if (isset($update) && !empty($update->download_url)) { + $update->download_url = $this->api->signDownloadUrl($update->download_url); + } + return $update; + } + public function onDisplayConfiguration($panel) + { + parent::onDisplayConfiguration($panel); + $panel->row('Branch', $this->branch); + $panel->row('Authentication enabled', $this->api->isAuthenticationEnabled() ? 'Yes' : 'No'); + $panel->row('API client', \get_class($this->api)); + } + } +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/load-v5p2.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/load-v5p2.php new file mode 100644 index 0000000..3299523 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/load-v5p2.php @@ -0,0 +1,17 @@ + \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Plugin\UpdateChecker::class, 'WCPOS\\Vendor\\Theme\\UpdateChecker' => \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Theme\UpdateChecker::class, 'WCPOS\\Vendor\\Vcs\\PluginUpdateChecker' => \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\PluginUpdateChecker::class, 'WCPOS\\Vendor\\Vcs\\ThemeUpdateChecker' => \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\ThemeUpdateChecker::class, 'GitHubApi' => \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\GitHubApi::class, 'BitBucketApi' => \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\BitBucketApi::class, 'GitLabApi' => \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\Vcs\GitLabApi::class) as $pucGeneralClass => $pucVersionedClass) { + \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5\PucFactory::addVersion($pucGeneralClass, $pucVersionedClass, '5.2'); + //Also add it to the minor-version factory in case the major-version factory + //was already defined by another, older version of the update checker. + \WCPOS\Vendor\YahnisElsts\PluginUpdateChecker\v5p2\PucFactory::addVersion($pucGeneralClass, $pucVersionedClass, '5.2'); +} diff --git a/vendor_prefixed/yahnis-elsts/plugin-update-checker/plugin-update-checker.php b/vendor_prefixed/yahnis-elsts/plugin-update-checker/plugin-update-checker.php new file mode 100644 index 0000000..d3f8af4 --- /dev/null +++ b/vendor_prefixed/yahnis-elsts/plugin-update-checker/plugin-update-checker.php @@ -0,0 +1,12 @@ +