class-addon-manager.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. <?php
  2. /**
  3. * WPSEO plugin file.
  4. *
  5. * @package WPSEO\Inc
  6. */
  7. /**
  8. * Represents the addon manager.
  9. */
  10. class WPSEO_Addon_Manager {
  11. /**
  12. * Holds the name of the transient.
  13. *
  14. * @var string
  15. */
  16. const SITE_INFORMATION_TRANSIENT = 'wpseo_site_information';
  17. /**
  18. * Holds the slug for YoastSEO free.
  19. *
  20. * @var string
  21. */
  22. const FREE_SLUG = 'yoast-seo-wordpress';
  23. /**
  24. * Holds the slug for YoastSEO Premium.
  25. *
  26. * @var string
  27. */
  28. const PREMIUM_SLUG = 'yoast-seo-wordpress-premium';
  29. /**
  30. * Holds the slug for Yoast News.
  31. *
  32. * @var string
  33. */
  34. const NEWS_SLUG = 'yoast-seo-news';
  35. /**
  36. * Holds the slug for Video.
  37. *
  38. * @var string
  39. */
  40. const VIDEO_SLUG = 'yoast-seo-video';
  41. /**
  42. * Holds the slug for WooCommerce.
  43. *
  44. * @var string
  45. */
  46. const WOOCOMMERCE_SLUG = 'yoast-seo-woocommerce';
  47. /**
  48. * Holds the slug for Local.
  49. *
  50. * @var string
  51. */
  52. const LOCAL_SLUG = 'yoast-seo-local';
  53. /**
  54. * The expected addon data.
  55. *
  56. * @var array
  57. */
  58. protected static $addons = [
  59. 'wp-seo-premium.php' => self::PREMIUM_SLUG,
  60. 'wpseo-news.php' => self::NEWS_SLUG,
  61. 'video-seo.php' => self::VIDEO_SLUG,
  62. 'wpseo-woocommerce.php' => self::WOOCOMMERCE_SLUG,
  63. 'local-seo.php' => self::LOCAL_SLUG,
  64. ];
  65. /**
  66. * Holds the site information data.
  67. *
  68. * @var object
  69. */
  70. private $site_information;
  71. /**
  72. * Hooks into WordPress.
  73. *
  74. * @codeCoverageIgnore
  75. *
  76. * @return void
  77. */
  78. public function register_hooks() {
  79. add_filter( 'pre_set_site_transient_update_plugins', [ $this, 'check_for_updates' ] );
  80. add_filter( 'plugins_api', [ $this, 'get_plugin_information' ], 10, 3 );
  81. }
  82. /**
  83. * Gets the subscriptions for current site.
  84. *
  85. * @return stdClass The subscriptions.
  86. */
  87. public function get_subscriptions() {
  88. return $this->get_site_information()->subscriptions;
  89. }
  90. /**
  91. * Retrieves the subscription for the given slug.
  92. *
  93. * @param string $slug The plugin slug to retrieve.
  94. *
  95. * @return stdClass|false Subscription data when found, false when not found.
  96. */
  97. public function get_subscription( $slug ) {
  98. foreach ( $this->get_subscriptions() as $subscription ) {
  99. if ( $subscription->product->slug === $slug ) {
  100. return $subscription;
  101. }
  102. }
  103. return false;
  104. }
  105. /**
  106. * Retrieves a list of (subscription) slugs by the active addons.
  107. *
  108. * @return array The slugs.
  109. */
  110. public function get_subscriptions_for_active_addons() {
  111. $active_addons = array_keys( $this->get_active_addons() );
  112. $subscription_slugs = array_map( [ $this, 'get_slug_by_plugin_file' ], $active_addons );
  113. $subscriptions = [];
  114. foreach ( $subscription_slugs as $subscription_slug ) {
  115. $subscriptions[ $subscription_slug ] = $this->get_subscription( $subscription_slug );
  116. }
  117. return $subscriptions;
  118. }
  119. /**
  120. * Retrieves a list of versions for each addon.
  121. *
  122. * @return array The addon versions.
  123. */
  124. public function get_installed_addons_versions() {
  125. $addon_versions = [];
  126. foreach ( $this->get_installed_addons() as $plugin_file => $installed_addon ) {
  127. $addon_versions[ $this->get_slug_by_plugin_file( $plugin_file ) ] = $installed_addon['Version'];
  128. }
  129. return $addon_versions;
  130. }
  131. /**
  132. * Retrieves the plugin information from the subscriptions.
  133. *
  134. * @param stdClass|false $data The result object. Default false.
  135. * @param string $action The type of information being requested from the Plugin Installation API.
  136. * @param stdClass $args Plugin API arguments.
  137. *
  138. * @return object Extended plugin data.
  139. */
  140. public function get_plugin_information( $data, $action, $args ) {
  141. if ( $action !== 'plugin_information' ) {
  142. return $data;
  143. }
  144. if ( ! isset( $args->slug ) ) {
  145. return $data;
  146. }
  147. $subscription = $this->get_subscription( $args->slug );
  148. if ( ! $subscription || $this->has_subscription_expired( $subscription ) ) {
  149. return $data;
  150. }
  151. return $this->convert_subscription_to_plugin( $subscription );
  152. }
  153. /**
  154. * Checks if the subscription for the given slug is valid.
  155. *
  156. * @param string $slug The plugin slug to retrieve.
  157. *
  158. * @return bool True when the subscription is valid.
  159. */
  160. public function has_valid_subscription( $slug ) {
  161. $subscription = $this->get_subscription( $slug );
  162. // An non-existing subscription is never valid.
  163. if ( $subscription === false ) {
  164. return false;
  165. }
  166. return ! $this->has_subscription_expired( $subscription );
  167. }
  168. /**
  169. * Checks if there are addon updates.
  170. *
  171. * @param stdClass|mixed $data The current data for update_plugins.
  172. *
  173. * @return stdClass Extended data for update_plugins.
  174. */
  175. public function check_for_updates( $data ) {
  176. if ( empty( $data ) ) {
  177. return $data;
  178. }
  179. foreach ( $this->get_installed_addons() as $plugin_file => $installed_plugin ) {
  180. $subscription_slug = $this->get_slug_by_plugin_file( $plugin_file );
  181. $subscription = $this->get_subscription( $subscription_slug );
  182. if ( ! $subscription || $this->has_subscription_expired( $subscription ) ) {
  183. continue;
  184. }
  185. if ( version_compare( $installed_plugin['Version'], $subscription->product->version, '<' ) ) {
  186. $data->response[ $plugin_file ] = $this->convert_subscription_to_plugin( $subscription );
  187. }
  188. }
  189. return $data;
  190. }
  191. /**
  192. * Checks whether a plugin expiry date has been passed.
  193. *
  194. * @param stdClass $subscription Plugin subscription.
  195. *
  196. * @return bool Has the plugin expired.
  197. */
  198. protected function has_subscription_expired( $subscription ) {
  199. return ( strtotime( $subscription->expiry_date ) - time() ) < 0;
  200. }
  201. /**
  202. * Converts a subscription to plugin based format.
  203. *
  204. * @param stdClass $subscription The subscription to convert.
  205. *
  206. * @return stdClass The converted subscription.
  207. */
  208. protected function convert_subscription_to_plugin( $subscription ) {
  209. return (object) [
  210. 'new_version' => $subscription->product->version,
  211. 'name' => $subscription->product->name,
  212. 'slug' => $subscription->product->slug,
  213. 'url' => $subscription->product->store_url,
  214. 'last_update' => $subscription->product->last_updated,
  215. 'homepage' => $subscription->product->store_url,
  216. 'download_link' => $subscription->product->download,
  217. 'package' => $subscription->product->download,
  218. 'sections' =>
  219. [
  220. 'changelog' => $subscription->product->changelog,
  221. ],
  222. ];
  223. }
  224. /**
  225. * Checks if the given plugin_file belongs to a Yoast addon.
  226. *
  227. * @param string $plugin_file Path to the plugin.
  228. *
  229. * @return bool True when plugin file is for a Yoast addon.
  230. */
  231. protected function is_yoast_addon( $plugin_file ) {
  232. return $this->get_slug_by_plugin_file( $plugin_file ) !== '';
  233. }
  234. /**
  235. * Retrieves the addon slug by given plugin file path.
  236. *
  237. * @param string $plugin_file The file path to the plugin.
  238. *
  239. * @return string The slug when found or empty string when not.
  240. */
  241. protected function get_slug_by_plugin_file( $plugin_file ) {
  242. $addons = self::$addons;
  243. // Yoast SEO Free isn't an addon, but we needed it in Premium to fetch translations.
  244. if ( WPSEO_Utils::is_yoast_seo_premium() ) {
  245. $addons['wp-seo.php'] = self::FREE_SLUG;
  246. }
  247. foreach ( $addons as $addon => $addon_slug ) {
  248. if ( strpos( $plugin_file, $addon ) !== false ) {
  249. return $addon_slug;
  250. }
  251. }
  252. return '';
  253. }
  254. /**
  255. * Retrieves the installed Yoast addons.
  256. *
  257. * @return array The installed plugins.
  258. */
  259. protected function get_installed_addons() {
  260. return $this->filter_by_key( $this->get_plugins(), [ $this, 'is_yoast_addon' ] );
  261. }
  262. /**
  263. * Retrieves a list of active addons.
  264. *
  265. * @return array The active addons.
  266. */
  267. protected function get_active_addons() {
  268. return $this->filter_by_key( $this->get_installed_addons(), [ $this, 'is_plugin_active' ] );
  269. }
  270. /**
  271. * Retrieves the current sites from the API.
  272. *
  273. * @codeCoverageIgnore
  274. *
  275. * @return bool|stdClass Object when request is successful. False if not.
  276. */
  277. protected function request_current_sites() {
  278. $api_request = new WPSEO_MyYoast_Api_Request( 'sites/current' );
  279. if ( $api_request->fire() ) {
  280. return $api_request->get_response();
  281. }
  282. return $this->get_site_information_default();
  283. }
  284. /**
  285. * Retrieves the transient value with the site information.
  286. *
  287. * @codeCoverageIgnore
  288. *
  289. * @return stdClass|false The transient value.
  290. */
  291. protected function get_site_information_transient() {
  292. global $pagenow;
  293. // Force re-check on license & dashboard pages.
  294. $current_page = $this->get_current_page();
  295. // Check whether the licenses are valid or whether we need to show notifications.
  296. $exclude_cache = ( $current_page === 'wpseo_licenses' || $current_page === 'wpseo_dashboard' );
  297. // Also do a fresh request on Plugins & Core Update pages.
  298. $exclude_cache = $exclude_cache || $pagenow === 'plugins.php';
  299. $exclude_cache = $exclude_cache || $pagenow === 'update-core.php';
  300. if ( $exclude_cache ) {
  301. return false;
  302. }
  303. return get_transient( self::SITE_INFORMATION_TRANSIENT );
  304. }
  305. /**
  306. * Returns the current page.
  307. *
  308. * @codeCoverageIgnore
  309. *
  310. * @return string The current page.
  311. */
  312. protected function get_current_page() {
  313. return filter_input( INPUT_GET, 'page' );
  314. }
  315. /**
  316. * Sets the site information transient.
  317. *
  318. * @codeCoverageIgnore
  319. *
  320. * @param stdClass $site_information The site information to save.
  321. *
  322. * @return void
  323. */
  324. protected function set_site_information_transient( $site_information ) {
  325. set_transient( self::SITE_INFORMATION_TRANSIENT, $site_information, DAY_IN_SECONDS );
  326. }
  327. /**
  328. * Retrieves all installed WordPress plugins.
  329. *
  330. * @codeCoverageIgnore
  331. *
  332. * @return array The plugins.
  333. */
  334. protected function get_plugins() {
  335. return get_plugins();
  336. }
  337. /**
  338. * Checks if the given plugin file belongs to an active plugin.
  339. *
  340. * @codeCoverageIgnore
  341. *
  342. * @param string $plugin_file The file path to the plugin.
  343. *
  344. * @return bool True when plugin is active.
  345. */
  346. protected function is_plugin_active( $plugin_file ) {
  347. return is_plugin_active( $plugin_file );
  348. }
  349. /**
  350. * Returns an object with no subscriptions.
  351. *
  352. * @codeCoverageIgnore
  353. *
  354. * @return stdClass Site information.
  355. */
  356. protected function get_site_information_default() {
  357. return (object) [
  358. 'url' => WPSEO_Utils::get_home_url(),
  359. 'subscriptions' => [],
  360. ];
  361. }
  362. /**
  363. * Checks if there are any installed addons.
  364. *
  365. * @return bool True when there are installed Yoast addons.
  366. */
  367. protected function has_installed_addons() {
  368. $installed_addons = $this->get_installed_addons();
  369. return ! empty( $installed_addons );
  370. }
  371. /**
  372. * Filters the given array by its keys.
  373. *
  374. * This method is temporary. When WordPress has minimal PHP 5.6 support we can change this to:
  375. *
  376. * array_filter( $array_to_filter, $filter, ARRAY_FILTER_USE_KEY )
  377. *
  378. * @codeCoverageIgnore
  379. *
  380. * @param array $array_to_filter The array to filter.
  381. * @param callable $callback The filter callback.
  382. *
  383. * @return array The filtered array,
  384. */
  385. private function filter_by_key( $array_to_filter, $callback ) {
  386. $keys_to_filter = array_filter( array_keys( $array_to_filter ), $callback );
  387. $filtered_array = [];
  388. foreach ( $keys_to_filter as $filtered_key ) {
  389. $filtered_array[ $filtered_key ] = $array_to_filter[ $filtered_key ];
  390. }
  391. return $filtered_array;
  392. }
  393. /**
  394. * Maps the plugin API response.
  395. *
  396. * @param object $site_information Site information as received from the API.
  397. *
  398. * @return object Mapped site information.
  399. */
  400. protected function map_site_information( $site_information ) {
  401. return (object) [
  402. 'url' => $site_information->url,
  403. 'subscriptions' => array_map( [ $this, 'map_subscription' ], $site_information->subscriptions ),
  404. ];
  405. }
  406. /**
  407. * Maps a plugin subscription.
  408. *
  409. * @param object $subscription Subscription information as received from the API.
  410. *
  411. * @return object Mapped subscription.
  412. */
  413. protected function map_subscription( $subscription ) {
  414. // @codingStandardsIgnoreStart
  415. return (object) array(
  416. 'renewal_url' => $subscription->renewalUrl,
  417. 'expiry_date' => $subscription->expiryDate,
  418. 'product' => (object) array(
  419. 'version' => $subscription->product->version,
  420. 'name' => $subscription->product->name,
  421. 'slug' => $subscription->product->slug,
  422. 'last_updated' => $subscription->product->lastUpdated,
  423. 'store_url' => $subscription->product->storeUrl,
  424. // Ternary operator is necessary because download can be undefined.
  425. 'download' => isset( $subscription->product->download ) ? $subscription->product->download : null,
  426. 'changelog' => $subscription->product->changelog,
  427. ),
  428. );
  429. // @codingStandardsIgnoreStop
  430. }
  431. /**
  432. * Retrieves the site information.
  433. *
  434. * @return stdClass The site information.
  435. */
  436. private function get_site_information() {
  437. if ( ! $this->has_installed_addons() ) {
  438. return $this->get_site_information_default();
  439. }
  440. if ( $this->site_information === null ) {
  441. $this->site_information = $this->get_site_information_transient();
  442. }
  443. if ( $this->site_information ) {
  444. return $this->site_information;
  445. }
  446. $this->site_information = $this->request_current_sites();
  447. if ( $this->site_information ) {
  448. $this->site_information = $this->map_site_information( $this->site_information );
  449. $this->set_site_information_transient( $this->site_information );
  450. return $this->site_information;
  451. }
  452. return $this->get_site_information_default();
  453. }
  454. }