UpdateChecker.php 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997
  1. <?php
  2. if ( !class_exists('Puc_v4p11_UpdateChecker', false) ):
  3. abstract class Puc_v4p11_UpdateChecker {
  4. protected $filterSuffix = '';
  5. protected $updateTransient = '';
  6. protected $translationType = ''; //"plugin" or "theme".
  7. /**
  8. * Set to TRUE to enable error reporting. Errors are raised using trigger_error()
  9. * and should be logged to the standard PHP error log.
  10. * @var bool
  11. */
  12. public $debugMode = null;
  13. /**
  14. * @var string Where to store the update info.
  15. */
  16. public $optionName = '';
  17. /**
  18. * @var string The URL of the metadata file.
  19. */
  20. public $metadataUrl = '';
  21. /**
  22. * @var string Plugin or theme directory name.
  23. */
  24. public $directoryName = '';
  25. /**
  26. * @var string The slug that will be used in update checker hooks and remote API requests.
  27. * Usually matches the directory name unless the plugin/theme directory has been renamed.
  28. */
  29. public $slug = '';
  30. /**
  31. * @var Puc_v4p11_InstalledPackage
  32. */
  33. protected $package;
  34. /**
  35. * @var Puc_v4p11_Scheduler
  36. */
  37. public $scheduler;
  38. /**
  39. * @var Puc_v4p11_UpgraderStatus
  40. */
  41. protected $upgraderStatus;
  42. /**
  43. * @var Puc_v4p11_StateStore
  44. */
  45. protected $updateState;
  46. /**
  47. * @var array List of API errors triggered during the last checkForUpdates() call.
  48. */
  49. protected $lastRequestApiErrors = array();
  50. /**
  51. * @var string|mixed The default is 0 because parse_url() can return NULL or FALSE.
  52. */
  53. protected $cachedMetadataHost = 0;
  54. /**
  55. * @var Puc_v4p11_DebugBar_Extension|null
  56. */
  57. protected $debugBarExtension = null;
  58. public function __construct($metadataUrl, $directoryName, $slug = null, $checkPeriod = 12, $optionName = '') {
  59. $this->debugMode = (bool)(constant('WP_DEBUG'));
  60. $this->metadataUrl = $metadataUrl;
  61. $this->directoryName = $directoryName;
  62. $this->slug = !empty($slug) ? $slug : $this->directoryName;
  63. $this->optionName = $optionName;
  64. if ( empty($this->optionName) ) {
  65. //BC: Initially the library only supported plugin updates and didn't use type prefixes
  66. //in the option name. Lets use the same prefix-less name when possible.
  67. if ( $this->filterSuffix === '' ) {
  68. $this->optionName = 'external_updates-' . $this->slug;
  69. } else {
  70. $this->optionName = $this->getUniqueName('external_updates');
  71. }
  72. }
  73. $this->package = $this->createInstalledPackage();
  74. $this->scheduler = $this->createScheduler($checkPeriod);
  75. $this->upgraderStatus = new Puc_v4p11_UpgraderStatus();
  76. $this->updateState = new Puc_v4p11_StateStore($this->optionName);
  77. if ( did_action('init') ) {
  78. $this->loadTextDomain();
  79. } else {
  80. add_action('init', array($this, 'loadTextDomain'));
  81. }
  82. $this->installHooks();
  83. }
  84. /**
  85. * @internal
  86. */
  87. public function loadTextDomain() {
  88. //We're not using load_plugin_textdomain() or its siblings because figuring out where
  89. //the library is located (plugin, mu-plugin, theme, custom wp-content paths) is messy.
  90. $domain = 'plugin-update-checker';
  91. $locale = apply_filters(
  92. 'plugin_locale',
  93. (is_admin() && function_exists('get_user_locale')) ? get_user_locale() : get_locale(),
  94. $domain
  95. );
  96. $moFile = $domain . '-' . $locale . '.mo';
  97. $path = realpath(dirname(__FILE__) . '/../../languages');
  98. if ($path && file_exists($path)) {
  99. load_textdomain($domain, $path . '/' . $moFile);
  100. }
  101. }
  102. protected function installHooks() {
  103. //Insert our update info into the update array maintained by WP.
  104. add_filter('site_transient_' . $this->updateTransient, array($this,'injectUpdate'));
  105. //Insert translation updates into the update list.
  106. add_filter('site_transient_' . $this->updateTransient, array($this, 'injectTranslationUpdates'));
  107. //Clear translation updates when WP clears the update cache.
  108. //This needs to be done directly because the library doesn't actually remove obsolete plugin updates,
  109. //it just hides them (see getUpdate()). We can't do that with translations - too much disk I/O.
  110. add_action(
  111. 'delete_site_transient_' . $this->updateTransient,
  112. array($this, 'clearCachedTranslationUpdates')
  113. );
  114. //Rename the update directory to be the same as the existing directory.
  115. if ( $this->directoryName !== '.' ) {
  116. add_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10, 3);
  117. }
  118. //Allow HTTP requests to the metadata URL even if it's on a local host.
  119. add_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10, 2);
  120. //DebugBar integration.
  121. if ( did_action('plugins_loaded') ) {
  122. $this->maybeInitDebugBar();
  123. } else {
  124. add_action('plugins_loaded', array($this, 'maybeInitDebugBar'));
  125. }
  126. }
  127. /**
  128. * Remove hooks that were added by this update checker instance.
  129. */
  130. public function removeHooks() {
  131. remove_filter('site_transient_' . $this->updateTransient, array($this,'injectUpdate'));
  132. remove_filter('site_transient_' . $this->updateTransient, array($this, 'injectTranslationUpdates'));
  133. remove_action(
  134. 'delete_site_transient_' . $this->updateTransient,
  135. array($this, 'clearCachedTranslationUpdates')
  136. );
  137. remove_filter('upgrader_source_selection', array($this, 'fixDirectoryName'), 10);
  138. remove_filter('http_request_host_is_external', array($this, 'allowMetadataHost'), 10);
  139. remove_action('plugins_loaded', array($this, 'maybeInitDebugBar'));
  140. remove_action('init', array($this, 'loadTextDomain'));
  141. if ( $this->scheduler ) {
  142. $this->scheduler->removeHooks();
  143. }
  144. if ( $this->debugBarExtension ) {
  145. $this->debugBarExtension->removeHooks();
  146. }
  147. }
  148. /**
  149. * Check if the current user has the required permissions to install updates.
  150. *
  151. * @return bool
  152. */
  153. abstract public function userCanInstallUpdates();
  154. /**
  155. * Explicitly allow HTTP requests to the metadata URL.
  156. *
  157. * WordPress has a security feature where the HTTP API will reject all requests that are sent to
  158. * another site hosted on the same server as the current site (IP match), a local host, or a local
  159. * IP, unless the host exactly matches the current site.
  160. *
  161. * This feature is opt-in (at least in WP 4.4). Apparently some people enable it.
  162. *
  163. * That can be a problem when you're developing your plugin and you decide to host the update information
  164. * on the same server as your test site. Update requests will mysteriously fail.
  165. *
  166. * We fix that by adding an exception for the metadata host.
  167. *
  168. * @param bool $allow
  169. * @param string $host
  170. * @return bool
  171. */
  172. public function allowMetadataHost($allow, $host) {
  173. if ( $this->cachedMetadataHost === 0 ) {
  174. $this->cachedMetadataHost = parse_url($this->metadataUrl, PHP_URL_HOST);
  175. }
  176. if ( is_string($this->cachedMetadataHost) && (strtolower($host) === strtolower($this->cachedMetadataHost)) ) {
  177. return true;
  178. }
  179. return $allow;
  180. }
  181. /**
  182. * Create a package instance that represents this plugin or theme.
  183. *
  184. * @return Puc_v4p11_InstalledPackage
  185. */
  186. abstract protected function createInstalledPackage();
  187. /**
  188. * @return Puc_v4p11_InstalledPackage
  189. */
  190. public function getInstalledPackage() {
  191. return $this->package;
  192. }
  193. /**
  194. * Create an instance of the scheduler.
  195. *
  196. * This is implemented as a method to make it possible for plugins to subclass the update checker
  197. * and substitute their own scheduler.
  198. *
  199. * @param int $checkPeriod
  200. * @return Puc_v4p11_Scheduler
  201. */
  202. abstract protected function createScheduler($checkPeriod);
  203. /**
  204. * Check for updates. The results are stored in the DB option specified in $optionName.
  205. *
  206. * @return Puc_v4p11_Update|null
  207. */
  208. public function checkForUpdates() {
  209. $installedVersion = $this->getInstalledVersion();
  210. //Fail silently if we can't find the plugin/theme or read its header.
  211. if ( $installedVersion === null ) {
  212. $this->triggerError(
  213. sprintf('Skipping update check for %s - installed version unknown.', $this->slug),
  214. E_USER_WARNING
  215. );
  216. return null;
  217. }
  218. //Start collecting API errors.
  219. $this->lastRequestApiErrors = array();
  220. add_action('puc_api_error', array($this, 'collectApiErrors'), 10, 4);
  221. $state = $this->updateState;
  222. $state->setLastCheckToNow()
  223. ->setCheckedVersion($installedVersion)
  224. ->save(); //Save before checking in case something goes wrong
  225. $state->setUpdate($this->requestUpdate());
  226. $state->save();
  227. //Stop collecting API errors.
  228. remove_action('puc_api_error', array($this, 'collectApiErrors'), 10);
  229. return $this->getUpdate();
  230. }
  231. /**
  232. * Load the update checker state from the DB.
  233. *
  234. * @return Puc_v4p11_StateStore
  235. */
  236. public function getUpdateState() {
  237. return $this->updateState->lazyLoad();
  238. }
  239. /**
  240. * Reset update checker state - i.e. last check time, cached update data and so on.
  241. *
  242. * Call this when your plugin is being uninstalled, or if you want to
  243. * clear the update cache.
  244. */
  245. public function resetUpdateState() {
  246. $this->updateState->delete();
  247. }
  248. /**
  249. * Get the details of the currently available update, if any.
  250. *
  251. * If no updates are available, or if the last known update version is below or equal
  252. * to the currently installed version, this method will return NULL.
  253. *
  254. * Uses cached update data. To retrieve update information straight from
  255. * the metadata URL, call requestUpdate() instead.
  256. *
  257. * @return Puc_v4p11_Update|null
  258. */
  259. public function getUpdate() {
  260. $update = $this->updateState->getUpdate();
  261. //Is there an update available?
  262. if ( isset($update) ) {
  263. //Check if the update is actually newer than the currently installed version.
  264. $installedVersion = $this->getInstalledVersion();
  265. if ( ($installedVersion !== null) && version_compare($update->version, $installedVersion, '>') ){
  266. return $update;
  267. }
  268. }
  269. return null;
  270. }
  271. /**
  272. * Retrieve the latest update (if any) from the configured API endpoint.
  273. *
  274. * Subclasses should run the update through filterUpdateResult before returning it.
  275. *
  276. * @return Puc_v4p11_Update An instance of Update, or NULL when no updates are available.
  277. */
  278. abstract public function requestUpdate();
  279. /**
  280. * Filter the result of a requestUpdate() call.
  281. *
  282. * @param Puc_v4p11_Update|null $update
  283. * @param array|WP_Error|null $httpResult The value returned by wp_remote_get(), if any.
  284. * @return Puc_v4p11_Update
  285. */
  286. protected function filterUpdateResult($update, $httpResult = null) {
  287. //Let plugins/themes modify the update.
  288. $update = apply_filters($this->getUniqueName('request_update_result'), $update, $httpResult);
  289. $this->fixSupportedWordpressVersion($update);
  290. if ( isset($update, $update->translations) ) {
  291. //Keep only those translation updates that apply to this site.
  292. $update->translations = $this->filterApplicableTranslations($update->translations);
  293. }
  294. return $update;
  295. }
  296. /**
  297. * The "Tested up to" field in the plugin metadata is supposed to be in the form of "major.minor",
  298. * while WordPress core's list_plugin_updates() expects the $update->tested field to be an exact
  299. * version, e.g. "major.minor.patch", to say it's compatible. In other case it shows
  300. * "Compatibility: Unknown".
  301. * The function mimics how wordpress.org API crafts the "tested" field out of "Tested up to".
  302. *
  303. * @param Puc_v4p11_Metadata|null $update
  304. */
  305. protected function fixSupportedWordpressVersion(Puc_v4p11_Metadata $update = null) {
  306. if ( !isset($update->tested) || !preg_match('/^\d++\.\d++$/', $update->tested) ) {
  307. return;
  308. }
  309. $actualWpVersions = array();
  310. $wpVersion = $GLOBALS['wp_version'];
  311. if ( function_exists('get_core_updates') ) {
  312. $coreUpdates = get_core_updates();
  313. if ( is_array($coreUpdates) ) {
  314. foreach ($coreUpdates as $coreUpdate) {
  315. if ( isset($coreUpdate->current) ) {
  316. $actualWpVersions[] = $coreUpdate->current;
  317. }
  318. }
  319. }
  320. }
  321. $actualWpVersions[] = $wpVersion;
  322. $actualWpPatchNumber = null;
  323. foreach ($actualWpVersions as $version) {
  324. if ( preg_match('/^(?P<majorMinor>\d++\.\d++)(?:\.(?P<patch>\d++))?/', $version, $versionParts) ) {
  325. if ( $versionParts['majorMinor'] === $update->tested ) {
  326. $patch = isset($versionParts['patch']) ? intval($versionParts['patch']) : 0;
  327. if ( $actualWpPatchNumber === null ) {
  328. $actualWpPatchNumber = $patch;
  329. } else {
  330. $actualWpPatchNumber = max($actualWpPatchNumber, $patch);
  331. }
  332. }
  333. }
  334. }
  335. if ( $actualWpPatchNumber === null ) {
  336. $actualWpPatchNumber = 999;
  337. }
  338. if ( $actualWpPatchNumber > 0 ) {
  339. $update->tested .= '.' . $actualWpPatchNumber;
  340. }
  341. }
  342. /**
  343. * Get the currently installed version of the plugin or theme.
  344. *
  345. * @return string|null Version number.
  346. */
  347. public function getInstalledVersion() {
  348. return $this->package->getInstalledVersion();
  349. }
  350. /**
  351. * Get the full path of the plugin or theme directory.
  352. *
  353. * @return string
  354. */
  355. public function getAbsoluteDirectoryPath() {
  356. return $this->package->getAbsoluteDirectoryPath();
  357. }
  358. /**
  359. * Trigger a PHP error, but only when $debugMode is enabled.
  360. *
  361. * @param string $message
  362. * @param int $errorType
  363. */
  364. public function triggerError($message, $errorType) {
  365. if ( $this->isDebugModeEnabled() ) {
  366. trigger_error($message, $errorType);
  367. }
  368. }
  369. /**
  370. * @return bool
  371. */
  372. protected function isDebugModeEnabled() {
  373. if ( $this->debugMode === null ) {
  374. $this->debugMode = (bool)(constant('WP_DEBUG'));
  375. }
  376. return $this->debugMode;
  377. }
  378. /**
  379. * Get the full name of an update checker filter, action or DB entry.
  380. *
  381. * This method adds the "puc_" prefix and the "-$slug" suffix to the filter name.
  382. * For example, "pre_inject_update" becomes "puc_pre_inject_update-plugin-slug".
  383. *
  384. * @param string $baseTag
  385. * @return string
  386. */
  387. public function getUniqueName($baseTag) {
  388. $name = 'puc_' . $baseTag;
  389. if ( $this->filterSuffix !== '' ) {
  390. $name .= '_' . $this->filterSuffix;
  391. }
  392. return $name . '-' . $this->slug;
  393. }
  394. /**
  395. * Store API errors that are generated when checking for updates.
  396. *
  397. * @internal
  398. * @param WP_Error $error
  399. * @param array|null $httpResponse
  400. * @param string|null $url
  401. * @param string|null $slug
  402. */
  403. public function collectApiErrors($error, $httpResponse = null, $url = null, $slug = null) {
  404. if ( isset($slug) && ($slug !== $this->slug) ) {
  405. return;
  406. }
  407. $this->lastRequestApiErrors[] = array(
  408. 'error' => $error,
  409. 'httpResponse' => $httpResponse,
  410. 'url' => $url,
  411. );
  412. }
  413. /**
  414. * @return array
  415. */
  416. public function getLastRequestApiErrors() {
  417. return $this->lastRequestApiErrors;
  418. }
  419. /* -------------------------------------------------------------------
  420. * PUC filters and filter utilities
  421. * -------------------------------------------------------------------
  422. */
  423. /**
  424. * Register a callback for one of the update checker filters.
  425. *
  426. * Identical to add_filter(), except it automatically adds the "puc_" prefix
  427. * and the "-$slug" suffix to the filter name. For example, "request_info_result"
  428. * becomes "puc_request_info_result-your_plugin_slug".
  429. *
  430. * @param string $tag
  431. * @param callable $callback
  432. * @param int $priority
  433. * @param int $acceptedArgs
  434. */
  435. public function addFilter($tag, $callback, $priority = 10, $acceptedArgs = 1) {
  436. add_filter($this->getUniqueName($tag), $callback, $priority, $acceptedArgs);
  437. }
  438. /* -------------------------------------------------------------------
  439. * Inject updates
  440. * -------------------------------------------------------------------
  441. */
  442. /**
  443. * Insert the latest update (if any) into the update list maintained by WP.
  444. *
  445. * @param stdClass $updates Update list.
  446. * @return stdClass Modified update list.
  447. */
  448. public function injectUpdate($updates) {
  449. //Is there an update to insert?
  450. $update = $this->getUpdate();
  451. if ( !$this->shouldShowUpdates() ) {
  452. $update = null;
  453. }
  454. if ( !empty($update) ) {
  455. //Let plugins filter the update info before it's passed on to WordPress.
  456. $update = apply_filters($this->getUniqueName('pre_inject_update'), $update);
  457. $updates = $this->addUpdateToList($updates, $update->toWpFormat());
  458. } else {
  459. //Clean up any stale update info.
  460. $updates = $this->removeUpdateFromList($updates);
  461. //Add a placeholder item to the "no_update" list to enable auto-update support.
  462. //If we don't do this, the option to enable automatic updates will only show up
  463. //when an update is available.
  464. $updates = $this->addNoUpdateItem($updates);
  465. }
  466. return $updates;
  467. }
  468. /**
  469. * @param stdClass|null $updates
  470. * @param stdClass|array $updateToAdd
  471. * @return stdClass
  472. */
  473. protected function addUpdateToList($updates, $updateToAdd) {
  474. if ( !is_object($updates) ) {
  475. $updates = new stdClass();
  476. $updates->response = array();
  477. }
  478. $updates->response[$this->getUpdateListKey()] = $updateToAdd;
  479. return $updates;
  480. }
  481. /**
  482. * @param stdClass|null $updates
  483. * @return stdClass|null
  484. */
  485. protected function removeUpdateFromList($updates) {
  486. if ( isset($updates, $updates->response) ) {
  487. unset($updates->response[$this->getUpdateListKey()]);
  488. }
  489. return $updates;
  490. }
  491. /**
  492. * See this post for more information:
  493. * @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/
  494. *
  495. * @param stdClass|null $updates
  496. * @return stdClass
  497. */
  498. protected function addNoUpdateItem($updates) {
  499. if ( !is_object($updates) ) {
  500. $updates = new stdClass();
  501. $updates->response = array();
  502. $updates->no_update = array();
  503. } else if ( !isset($updates->no_update) ) {
  504. $updates->no_update = array();
  505. }
  506. $updates->no_update[$this->getUpdateListKey()] = (object) $this->getNoUpdateItemFields();
  507. return $updates;
  508. }
  509. /**
  510. * Subclasses should override this method to add fields that are specific to plugins or themes.
  511. * @return array
  512. */
  513. protected function getNoUpdateItemFields() {
  514. return array(
  515. 'new_version' => $this->getInstalledVersion(),
  516. 'url' => '',
  517. 'package' => '',
  518. 'requires_php' => '',
  519. );
  520. }
  521. /**
  522. * Get the key that will be used when adding updates to the update list that's maintained
  523. * by the WordPress core. The list is always an associative array, but the key is different
  524. * for plugins and themes.
  525. *
  526. * @return string
  527. */
  528. abstract protected function getUpdateListKey();
  529. /**
  530. * Should we show available updates?
  531. *
  532. * Usually the answer is "yes", but there are exceptions. For example, WordPress doesn't
  533. * support automatic updates installation for mu-plugins, so PUC usually won't show update
  534. * notifications in that case. See the plugin-specific subclass for details.
  535. *
  536. * Note: This method only applies to updates that are displayed (or not) in the WordPress
  537. * admin. It doesn't affect APIs like requestUpdate and getUpdate.
  538. *
  539. * @return bool
  540. */
  541. protected function shouldShowUpdates() {
  542. return true;
  543. }
  544. /* -------------------------------------------------------------------
  545. * JSON-based update API
  546. * -------------------------------------------------------------------
  547. */
  548. /**
  549. * Retrieve plugin or theme metadata from the JSON document at $this->metadataUrl.
  550. *
  551. * @param string $metaClass Parse the JSON as an instance of this class. It must have a static fromJson method.
  552. * @param string $filterRoot
  553. * @param array $queryArgs Additional query arguments.
  554. * @return array [Puc_v4p11_Metadata|null, array|WP_Error] A metadata instance and the value returned by wp_remote_get().
  555. */
  556. protected function requestMetadata($metaClass, $filterRoot, $queryArgs = array()) {
  557. //Query args to append to the URL. Plugins can add their own by using a filter callback (see addQueryArgFilter()).
  558. $queryArgs = array_merge(
  559. array(
  560. 'installed_version' => strval($this->getInstalledVersion()),
  561. 'php' => phpversion(),
  562. 'locale' => get_locale(),
  563. ),
  564. $queryArgs
  565. );
  566. $queryArgs = apply_filters($this->getUniqueName($filterRoot . '_query_args'), $queryArgs);
  567. //Various options for the wp_remote_get() call. Plugins can filter these, too.
  568. $options = array(
  569. 'timeout' => 10, //seconds
  570. 'headers' => array(
  571. 'Accept' => 'application/json',
  572. ),
  573. );
  574. $options = apply_filters($this->getUniqueName($filterRoot . '_options'), $options);
  575. //The metadata file should be at 'http://your-api.com/url/here/$slug/info.json'
  576. $url = $this->metadataUrl;
  577. if ( !empty($queryArgs) ){
  578. $url = add_query_arg($queryArgs, $url);
  579. }
  580. $result = wp_remote_get($url, $options);
  581. $result = apply_filters($this->getUniqueName('request_metadata_http_result'), $result, $url, $options);
  582. //Try to parse the response
  583. $status = $this->validateApiResponse($result);
  584. $metadata = null;
  585. if ( !is_wp_error($status) ){
  586. if ( version_compare(PHP_VERSION, '5.3', '>=') && (strpos($metaClass, '\\') === false) ) {
  587. $metaClass = __NAMESPACE__ . '\\' . $metaClass;
  588. }
  589. $metadata = call_user_func(array($metaClass, 'fromJson'), $result['body']);
  590. } else {
  591. do_action('puc_api_error', $status, $result, $url, $this->slug);
  592. $this->triggerError(
  593. sprintf('The URL %s does not point to a valid metadata file. ', $url)
  594. . $status->get_error_message(),
  595. E_USER_WARNING
  596. );
  597. }
  598. return array($metadata, $result);
  599. }
  600. /**
  601. * Check if $result is a successful update API response.
  602. *
  603. * @param array|WP_Error $result
  604. * @return true|WP_Error
  605. */
  606. protected function validateApiResponse($result) {
  607. if ( is_wp_error($result) ) { /** @var WP_Error $result */
  608. return new WP_Error($result->get_error_code(), 'WP HTTP Error: ' . $result->get_error_message());
  609. }
  610. if ( !isset($result['response']['code']) ) {
  611. return new WP_Error(
  612. 'puc_no_response_code',
  613. 'wp_remote_get() returned an unexpected result.'
  614. );
  615. }
  616. if ( $result['response']['code'] !== 200 ) {
  617. return new WP_Error(
  618. 'puc_unexpected_response_code',
  619. 'HTTP response code is ' . $result['response']['code'] . ' (expected: 200)'
  620. );
  621. }
  622. if ( empty($result['body']) ) {
  623. return new WP_Error('puc_empty_response', 'The metadata file appears to be empty.');
  624. }
  625. return true;
  626. }
  627. /* -------------------------------------------------------------------
  628. * Language packs / Translation updates
  629. * -------------------------------------------------------------------
  630. */
  631. /**
  632. * Filter a list of translation updates and return a new list that contains only updates
  633. * that apply to the current site.
  634. *
  635. * @param array $translations
  636. * @return array
  637. */
  638. protected function filterApplicableTranslations($translations) {
  639. $languages = array_flip(array_values(get_available_languages()));
  640. $installedTranslations = $this->getInstalledTranslations();
  641. $applicableTranslations = array();
  642. foreach ($translations as $translation) {
  643. //Does it match one of the available core languages?
  644. $isApplicable = array_key_exists($translation->language, $languages);
  645. //Is it more recent than an already-installed translation?
  646. if ( isset($installedTranslations[$translation->language]) ) {
  647. $updateTimestamp = strtotime($translation->updated);
  648. $installedTimestamp = strtotime($installedTranslations[$translation->language]['PO-Revision-Date']);
  649. $isApplicable = $updateTimestamp > $installedTimestamp;
  650. }
  651. if ( $isApplicable ) {
  652. $applicableTranslations[] = $translation;
  653. }
  654. }
  655. return $applicableTranslations;
  656. }
  657. /**
  658. * Get a list of installed translations for this plugin or theme.
  659. *
  660. * @return array
  661. */
  662. protected function getInstalledTranslations() {
  663. if ( !function_exists('wp_get_installed_translations') ) {
  664. return array();
  665. }
  666. $installedTranslations = wp_get_installed_translations($this->translationType . 's');
  667. if ( isset($installedTranslations[$this->directoryName]) ) {
  668. $installedTranslations = $installedTranslations[$this->directoryName];
  669. } else {
  670. $installedTranslations = array();
  671. }
  672. return $installedTranslations;
  673. }
  674. /**
  675. * Insert translation updates into the list maintained by WordPress.
  676. *
  677. * @param stdClass $updates
  678. * @return stdClass
  679. */
  680. public function injectTranslationUpdates($updates) {
  681. $translationUpdates = $this->getTranslationUpdates();
  682. if ( empty($translationUpdates) ) {
  683. return $updates;
  684. }
  685. //Being defensive.
  686. if ( !is_object($updates) ) {
  687. $updates = new stdClass();
  688. }
  689. if ( !isset($updates->translations) ) {
  690. $updates->translations = array();
  691. }
  692. //In case there's a name collision with a plugin or theme hosted on wordpress.org,
  693. //remove any preexisting updates that match our thing.
  694. $updates->translations = array_values(array_filter(
  695. $updates->translations,
  696. array($this, 'isNotMyTranslation')
  697. ));
  698. //Add our updates to the list.
  699. foreach($translationUpdates as $update) {
  700. $convertedUpdate = array_merge(
  701. array(
  702. 'type' => $this->translationType,
  703. 'slug' => $this->directoryName,
  704. 'autoupdate' => 0,
  705. //AFAICT, WordPress doesn't actually use the "version" field for anything.
  706. //But lets make sure it's there, just in case.
  707. 'version' => isset($update->version) ? $update->version : ('1.' . strtotime($update->updated)),
  708. ),
  709. (array)$update
  710. );
  711. $updates->translations[] = $convertedUpdate;
  712. }
  713. return $updates;
  714. }
  715. /**
  716. * Get a list of available translation updates.
  717. *
  718. * This method will return an empty array if there are no updates.
  719. * Uses cached update data.
  720. *
  721. * @return array
  722. */
  723. public function getTranslationUpdates() {
  724. return $this->updateState->getTranslations();
  725. }
  726. /**
  727. * Remove all cached translation updates.
  728. *
  729. * @see wp_clean_update_cache
  730. */
  731. public function clearCachedTranslationUpdates() {
  732. $this->updateState->setTranslations(array());
  733. }
  734. /**
  735. * Filter callback. Keeps only translations that *don't* match this plugin or theme.
  736. *
  737. * @param array $translation
  738. * @return bool
  739. */
  740. protected function isNotMyTranslation($translation) {
  741. $isMatch = isset($translation['type'], $translation['slug'])
  742. && ($translation['type'] === $this->translationType)
  743. && ($translation['slug'] === $this->directoryName);
  744. return !$isMatch;
  745. }
  746. /* -------------------------------------------------------------------
  747. * Fix directory name when installing updates
  748. * -------------------------------------------------------------------
  749. */
  750. /**
  751. * Rename the update directory to match the existing plugin/theme directory.
  752. *
  753. * When WordPress installs a plugin or theme update, it assumes that the ZIP file will contain
  754. * exactly one directory, and that the directory name will be the same as the directory where
  755. * the plugin or theme is currently installed.
  756. *
  757. * GitHub and other repositories provide ZIP downloads, but they often use directory names like
  758. * "project-branch" or "project-tag-hash". We need to change the name to the actual plugin folder.
  759. *
  760. * This is a hook callback. Don't call it from a plugin.
  761. *
  762. * @access protected
  763. *
  764. * @param string $source The directory to copy to /wp-content/plugins or /wp-content/themes. Usually a subdirectory of $remoteSource.
  765. * @param string $remoteSource WordPress has extracted the update to this directory.
  766. * @param WP_Upgrader $upgrader
  767. * @return string|WP_Error
  768. */
  769. public function fixDirectoryName($source, $remoteSource, $upgrader) {
  770. global $wp_filesystem;
  771. /** @var WP_Filesystem_Base $wp_filesystem */
  772. //Basic sanity checks.
  773. if ( !isset($source, $remoteSource, $upgrader, $upgrader->skin, $wp_filesystem) ) {
  774. return $source;
  775. }
  776. //If WordPress is upgrading anything other than our plugin/theme, leave the directory name unchanged.
  777. if ( !$this->isBeingUpgraded($upgrader) ) {
  778. return $source;
  779. }
  780. //Rename the source to match the existing directory.
  781. $correctedSource = trailingslashit($remoteSource) . $this->directoryName . '/';
  782. if ( $source !== $correctedSource ) {
  783. //The update archive should contain a single directory that contains the rest of plugin/theme files.
  784. //Otherwise, WordPress will try to copy the entire working directory ($source == $remoteSource).
  785. //We can't rename $remoteSource because that would break WordPress code that cleans up temporary files
  786. //after update.
  787. if ( $this->isBadDirectoryStructure($remoteSource) ) {
  788. return new WP_Error(
  789. 'puc-incorrect-directory-structure',
  790. sprintf(
  791. 'The directory structure of the update is incorrect. All files should be inside ' .
  792. 'a directory named <span class="code">%s</span>, not at the root of the ZIP archive.',
  793. htmlentities($this->slug)
  794. )
  795. );
  796. }
  797. /** @var WP_Upgrader_Skin $upgrader ->skin */
  798. $upgrader->skin->feedback(sprintf(
  799. 'Renaming %s to %s&#8230;',
  800. '<span class="code">' . basename($source) . '</span>',
  801. '<span class="code">' . $this->directoryName . '</span>'
  802. ));
  803. if ( $wp_filesystem->move($source, $correctedSource, true) ) {
  804. $upgrader->skin->feedback('Directory successfully renamed.');
  805. return $correctedSource;
  806. } else {
  807. return new WP_Error(
  808. 'puc-rename-failed',
  809. 'Unable to rename the update to match the existing directory.'
  810. );
  811. }
  812. }
  813. return $source;
  814. }
  815. /**
  816. * Is there an update being installed right now, for this plugin or theme?
  817. *
  818. * @param WP_Upgrader|null $upgrader The upgrader that's performing the current update.
  819. * @return bool
  820. */
  821. abstract public function isBeingUpgraded($upgrader = null);
  822. /**
  823. * Check for incorrect update directory structure. An update must contain a single directory,
  824. * all other files should be inside that directory.
  825. *
  826. * @param string $remoteSource Directory path.
  827. * @return bool
  828. */
  829. protected function isBadDirectoryStructure($remoteSource) {
  830. global $wp_filesystem;
  831. /** @var WP_Filesystem_Base $wp_filesystem */
  832. $sourceFiles = $wp_filesystem->dirlist($remoteSource);
  833. if ( is_array($sourceFiles) ) {
  834. $sourceFiles = array_keys($sourceFiles);
  835. $firstFilePath = trailingslashit($remoteSource) . $sourceFiles[0];
  836. return (count($sourceFiles) > 1) || (!$wp_filesystem->is_dir($firstFilePath));
  837. }
  838. //Assume it's fine.
  839. return false;
  840. }
  841. /* -------------------------------------------------------------------
  842. * DebugBar integration
  843. * -------------------------------------------------------------------
  844. */
  845. /**
  846. * Initialize the update checker Debug Bar plugin/add-on thingy.
  847. */
  848. public function maybeInitDebugBar() {
  849. if ( class_exists('Debug_Bar', false) && file_exists(dirname(__FILE__) . '/DebugBar') ) {
  850. $this->debugBarExtension = $this->createDebugBarExtension();
  851. }
  852. }
  853. protected function createDebugBarExtension() {
  854. return new Puc_v4p11_DebugBar_Extension($this);
  855. }
  856. /**
  857. * Display additional configuration details in the Debug Bar panel.
  858. *
  859. * @param Puc_v4p11_DebugBar_Panel $panel
  860. */
  861. public function onDisplayConfiguration($panel) {
  862. //Do nothing. Subclasses can use this to add additional info to the panel.
  863. }
  864. }
  865. endif;