UpdateChecker.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. <?php
  2. namespace YahnisElsts\PluginUpdateChecker\v5p1\Plugin;
  3. use YahnisElsts\PluginUpdateChecker\v5p1\InstalledPackage;
  4. use YahnisElsts\PluginUpdateChecker\v5p1\UpdateChecker as BaseUpdateChecker;
  5. use YahnisElsts\PluginUpdateChecker\v5p1\Scheduler;
  6. use YahnisElsts\PluginUpdateChecker\v5p1\DebugBar;
  7. if ( !class_exists(UpdateChecker::class, false) ):
  8. /**
  9. * A custom plugin update checker.
  10. *
  11. * @author Janis Elsts
  12. * @copyright 2018
  13. * @access public
  14. */
  15. class UpdateChecker extends BaseUpdateChecker {
  16. protected $updateTransient = 'update_plugins';
  17. protected $translationType = 'plugin';
  18. public $pluginAbsolutePath = ''; //Full path of the main plugin file.
  19. public $pluginFile = ''; //Plugin filename relative to the plugins directory. Many WP APIs use this to identify plugins.
  20. public $muPluginFile = ''; //For MU plugins, the plugin filename relative to the mu-plugins directory.
  21. /**
  22. * @var Package
  23. */
  24. protected $package;
  25. private $extraUi = null;
  26. /**
  27. * Class constructor.
  28. *
  29. * @param string $metadataUrl The URL of the plugin's metadata file.
  30. * @param string $pluginFile Fully qualified path to the main plugin file.
  31. * @param string $slug The plugin's 'slug'. If not specified, the filename part of $pluginFile sans '.php' will be used as the slug.
  32. * @param integer $checkPeriod How often to check for updates (in hours). Defaults to checking every 12 hours. Set to 0 to disable automatic update checks.
  33. * @param string $optionName Where to store book-keeping info about update checks. Defaults to 'external_updates-$slug'.
  34. * @param string $muPluginFile Optional. The plugin filename relative to the mu-plugins directory.
  35. */
  36. public function __construct($metadataUrl, $pluginFile, $slug = '', $checkPeriod = 12, $optionName = '', $muPluginFile = ''){
  37. $this->pluginAbsolutePath = $pluginFile;
  38. $this->pluginFile = plugin_basename($this->pluginAbsolutePath);
  39. $this->muPluginFile = $muPluginFile;
  40. //If no slug is specified, use the name of the main plugin file as the slug.
  41. //For example, 'my-cool-plugin/cool-plugin.php' becomes 'cool-plugin'.
  42. if ( empty($slug) ){
  43. $slug = basename($this->pluginFile, '.php');
  44. }
  45. //Plugin slugs must be unique.
  46. $slugCheckFilter = 'puc_is_slug_in_use-' . $slug;
  47. $slugUsedBy = apply_filters($slugCheckFilter, false);
  48. if ( $slugUsedBy ) {
  49. $this->triggerError(sprintf(
  50. 'Plugin slug "%s" is already in use by %s. Slugs must be unique.',
  51. $slug,
  52. $slugUsedBy
  53. ), E_USER_ERROR);
  54. }
  55. add_filter($slugCheckFilter, array($this, 'getAbsolutePath'));
  56. parent::__construct($metadataUrl, dirname($this->pluginFile), $slug, $checkPeriod, $optionName);
  57. //Backwards compatibility: If the plugin is a mu-plugin but no $muPluginFile is specified, assume
  58. //it's the same as $pluginFile given that it's not in a subdirectory (WP only looks in the base dir).
  59. if ( (strpbrk($this->pluginFile, '/\\') === false) && $this->isUnknownMuPlugin() ) {
  60. $this->muPluginFile = $this->pluginFile;
  61. }
  62. //To prevent a crash during plugin uninstallation, remove updater hooks when the user removes the plugin.
  63. //Details: https://github.com/YahnisElsts/plugin-update-checker/issues/138#issuecomment-335590964
  64. add_action('uninstall_' . $this->pluginFile, array($this, 'removeHooks'));
  65. $this->extraUi = new Ui($this);
  66. }
  67. /**
  68. * Create an instance of the scheduler.
  69. *
  70. * @param int $checkPeriod
  71. * @return Scheduler
  72. */
  73. protected function createScheduler($checkPeriod) {
  74. $scheduler = new Scheduler($this, $checkPeriod, array('load-plugins.php'));
  75. register_deactivation_hook($this->pluginFile, array($scheduler, 'removeUpdaterCron'));
  76. return $scheduler;
  77. }
  78. /**
  79. * Install the hooks required to run periodic update checks and inject update info
  80. * into WP data structures.
  81. *
  82. * @return void
  83. */
  84. protected function installHooks(){
  85. //Override requests for plugin information
  86. add_filter('plugins_api', array($this, 'injectInfo'), 20, 3);
  87. parent::installHooks();
  88. }
  89. /**
  90. * Remove update checker hooks.
  91. *
  92. * The intent is to prevent a fatal error that can happen if the plugin has an uninstall
  93. * hook. During uninstallation, WP includes the main plugin file (which creates a PUC instance),
  94. * the uninstall hook runs, WP deletes the plugin files and then updates some transients.
  95. * If PUC hooks are still around at this time, they could throw an error while trying to
  96. * autoload classes from files that no longer exist.
  97. *
  98. * The "site_transient_{$transient}" filter is the main problem here, but let's also remove
  99. * most other PUC hooks to be safe.
  100. *
  101. * @internal
  102. */
  103. public function removeHooks() {
  104. parent::removeHooks();
  105. $this->extraUi->removeHooks();
  106. $this->package->removeHooks();
  107. remove_filter('plugins_api', array($this, 'injectInfo'), 20);
  108. }
  109. /**
  110. * Retrieve plugin info from the configured API endpoint.
  111. *
  112. * @uses wp_remote_get()
  113. *
  114. * @param array $queryArgs Additional query arguments to append to the request. Optional.
  115. * @return PluginInfo
  116. */
  117. public function requestInfo($queryArgs = array()) {
  118. list($pluginInfo, $result) = $this->requestMetadata(
  119. PluginInfo::class,
  120. 'request_info',
  121. $queryArgs
  122. );
  123. if ( $pluginInfo !== null ) {
  124. /** @var PluginInfo $pluginInfo */
  125. $pluginInfo->filename = $this->pluginFile;
  126. $pluginInfo->slug = $this->slug;
  127. }
  128. $pluginInfo = apply_filters($this->getUniqueName('request_info_result'), $pluginInfo, $result);
  129. return $pluginInfo;
  130. }
  131. /**
  132. * Retrieve the latest update (if any) from the configured API endpoint.
  133. *
  134. * @uses UpdateChecker::requestInfo()
  135. *
  136. * @return Update|null An instance of Plugin Update, or NULL when no updates are available.
  137. */
  138. public function requestUpdate() {
  139. //For the sake of simplicity, this function just calls requestInfo()
  140. //and transforms the result accordingly.
  141. $pluginInfo = $this->requestInfo(array('checking_for_updates' => '1'));
  142. if ( $pluginInfo === null ){
  143. return null;
  144. }
  145. $update = Update::fromPluginInfo($pluginInfo);
  146. $update = $this->filterUpdateResult($update);
  147. return $update;
  148. }
  149. /**
  150. * Intercept plugins_api() calls that request information about our plugin and
  151. * use the configured API endpoint to satisfy them.
  152. *
  153. * @see plugins_api()
  154. *
  155. * @param mixed $result
  156. * @param string $action
  157. * @param array|object $args
  158. * @return mixed
  159. */
  160. public function injectInfo($result, $action = null, $args = null){
  161. $relevant = ($action == 'plugin_information') && isset($args->slug) && (
  162. ($args->slug == $this->slug) || ($args->slug == dirname($this->pluginFile))
  163. );
  164. if ( !$relevant ) {
  165. return $result;
  166. }
  167. $pluginInfo = $this->requestInfo();
  168. $this->fixSupportedWordpressVersion($pluginInfo);
  169. $pluginInfo = apply_filters($this->getUniqueName('pre_inject_info'), $pluginInfo);
  170. if ( $pluginInfo ) {
  171. return $pluginInfo->toWpFormat();
  172. }
  173. return $result;
  174. }
  175. protected function shouldShowUpdates() {
  176. //No update notifications for mu-plugins unless explicitly enabled. The MU plugin file
  177. //is usually different from the main plugin file so the update wouldn't show up properly anyway.
  178. return !$this->isUnknownMuPlugin();
  179. }
  180. /**
  181. * @param \stdClass|null $updates
  182. * @param \stdClass $updateToAdd
  183. * @return \stdClass
  184. */
  185. protected function addUpdateToList($updates, $updateToAdd) {
  186. if ( $this->package->isMuPlugin() ) {
  187. //WP does not support automatic update installation for mu-plugins, but we can
  188. //still display a notice.
  189. $updateToAdd->package = null;
  190. }
  191. return parent::addUpdateToList($updates, $updateToAdd);
  192. }
  193. /**
  194. * @param \stdClass|null $updates
  195. * @return \stdClass|null
  196. */
  197. protected function removeUpdateFromList($updates) {
  198. $updates = parent::removeUpdateFromList($updates);
  199. if ( !empty($this->muPluginFile) && isset($updates, $updates->response) ) {
  200. unset($updates->response[$this->muPluginFile]);
  201. }
  202. return $updates;
  203. }
  204. /**
  205. * For plugins, the update array is indexed by the plugin filename relative to the "plugins"
  206. * directory. Example: "plugin-name/plugin.php".
  207. *
  208. * @return string
  209. */
  210. protected function getUpdateListKey() {
  211. if ( $this->package->isMuPlugin() ) {
  212. return $this->muPluginFile;
  213. }
  214. return $this->pluginFile;
  215. }
  216. protected function getNoUpdateItemFields() {
  217. return array_merge(
  218. parent::getNoUpdateItemFields(),
  219. array(
  220. 'id' => $this->pluginFile,
  221. 'slug' => $this->slug,
  222. 'plugin' => $this->pluginFile,
  223. 'icons' => array(),
  224. 'banners' => array(),
  225. 'banners_rtl' => array(),
  226. 'tested' => '',
  227. 'compatibility' => new \stdClass(),
  228. )
  229. );
  230. }
  231. /**
  232. * Alias for isBeingUpgraded().
  233. *
  234. * @deprecated
  235. * @param \WP_Upgrader|null $upgrader The upgrader that's performing the current update.
  236. * @return bool
  237. */
  238. public function isPluginBeingUpgraded($upgrader = null) {
  239. return $this->isBeingUpgraded($upgrader);
  240. }
  241. /**
  242. * Is there an update being installed for this plugin, right now?
  243. *
  244. * @param \WP_Upgrader|null $upgrader
  245. * @return bool
  246. */
  247. public function isBeingUpgraded($upgrader = null) {
  248. return $this->upgraderStatus->isPluginBeingUpgraded($this->pluginFile, $upgrader);
  249. }
  250. /**
  251. * Get the details of the currently available update, if any.
  252. *
  253. * If no updates are available, or if the last known update version is below or equal
  254. * to the currently installed version, this method will return NULL.
  255. *
  256. * Uses cached update data. To retrieve update information straight from
  257. * the metadata URL, call requestUpdate() instead.
  258. *
  259. * @return Update|null
  260. */
  261. public function getUpdate() {
  262. $update = parent::getUpdate();
  263. if ( isset($update) ) {
  264. /** @var Update $update */
  265. $update->filename = $this->pluginFile;
  266. }
  267. return $update;
  268. }
  269. /**
  270. * Get the translated plugin title.
  271. *
  272. * @deprecated
  273. * @return string
  274. */
  275. public function getPluginTitle() {
  276. return $this->package->getPluginTitle();
  277. }
  278. /**
  279. * Check if the current user has the required permissions to install updates.
  280. *
  281. * @return bool
  282. */
  283. public function userCanInstallUpdates() {
  284. return current_user_can('update_plugins');
  285. }
  286. /**
  287. * Check if the plugin file is inside the mu-plugins directory.
  288. *
  289. * @deprecated
  290. * @return bool
  291. */
  292. protected function isMuPlugin() {
  293. return $this->package->isMuPlugin();
  294. }
  295. /**
  296. * MU plugins are partially supported, but only when we know which file in mu-plugins
  297. * corresponds to this plugin.
  298. *
  299. * @return bool
  300. */
  301. protected function isUnknownMuPlugin() {
  302. return empty($this->muPluginFile) && $this->package->isMuPlugin();
  303. }
  304. /**
  305. * Get absolute path to the main plugin file.
  306. *
  307. * @return string
  308. */
  309. public function getAbsolutePath() {
  310. return $this->pluginAbsolutePath;
  311. }
  312. /**
  313. * Register a callback for filtering query arguments.
  314. *
  315. * The callback function should take one argument - an associative array of query arguments.
  316. * It should return a modified array of query arguments.
  317. *
  318. * @uses add_filter() This method is a convenience wrapper for add_filter().
  319. *
  320. * @param callable $callback
  321. * @return void
  322. */
  323. public function addQueryArgFilter($callback){
  324. $this->addFilter('request_info_query_args', $callback);
  325. }
  326. /**
  327. * Register a callback for filtering arguments passed to wp_remote_get().
  328. *
  329. * The callback function should take one argument - an associative array of arguments -
  330. * and return a modified array or arguments. See the WP documentation on wp_remote_get()
  331. * for details on what arguments are available and how they work.
  332. *
  333. * @uses add_filter() This method is a convenience wrapper for add_filter().
  334. *
  335. * @param callable $callback
  336. * @return void
  337. */
  338. public function addHttpRequestArgFilter($callback) {
  339. $this->addFilter('request_info_options', $callback);
  340. }
  341. /**
  342. * Register a callback for filtering the plugin info retrieved from the external API.
  343. *
  344. * The callback function should take two arguments. If the plugin info was retrieved
  345. * successfully, the first argument passed will be an instance of PluginInfo. Otherwise,
  346. * it will be NULL. The second argument will be the corresponding return value of
  347. * wp_remote_get (see WP docs for details).
  348. *
  349. * The callback function should return a new or modified instance of PluginInfo or NULL.
  350. *
  351. * @uses add_filter() This method is a convenience wrapper for add_filter().
  352. *
  353. * @param callable $callback
  354. * @return void
  355. */
  356. public function addResultFilter($callback) {
  357. $this->addFilter('request_info_result', $callback, 10, 2);
  358. }
  359. protected function createDebugBarExtension() {
  360. return new DebugBar\PluginExtension($this);
  361. }
  362. /**
  363. * Create a package instance that represents this plugin or theme.
  364. *
  365. * @return InstalledPackage
  366. */
  367. protected function createInstalledPackage() {
  368. return new Package($this->pluginAbsolutePath, $this);
  369. }
  370. /**
  371. * @return Package
  372. */
  373. public function getInstalledPackage() {
  374. return $this->package;
  375. }
  376. }
  377. endif;