PucFactory.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. <?php
  2. namespace YahnisElsts\PluginUpdateChecker\v5p1;
  3. use YahnisElsts\PluginUpdateChecker\v5p1\Plugin;
  4. use YahnisElsts\PluginUpdateChecker\v5p1\Theme;
  5. use YahnisElsts\PluginUpdateChecker\v5p1\Vcs;
  6. if ( !class_exists(PucFactory::class, false) ):
  7. /**
  8. * A factory that builds update checker instances.
  9. *
  10. * When multiple versions of the same class have been loaded (e.g. PluginUpdateChecker 4.0
  11. * and 4.1), this factory will always use the latest available minor version. Register class
  12. * versions by calling {@link PucFactory::addVersion()}.
  13. *
  14. * At the moment it can only build instances of the UpdateChecker class. Other classes are
  15. * intended mainly for internal use and refer directly to specific implementations.
  16. */
  17. class PucFactory {
  18. protected static $classVersions = array();
  19. protected static $sorted = false;
  20. protected static $myMajorVersion = '';
  21. protected static $latestCompatibleVersion = '';
  22. /**
  23. * A wrapper method for buildUpdateChecker() that reads the metadata URL from the plugin or theme header.
  24. *
  25. * @param string $fullPath Full path to the main plugin file or the theme's style.css.
  26. * @param array $args Optional arguments. Keys should match the argument names of the buildUpdateChecker() method.
  27. * @return Plugin\UpdateChecker|Theme\UpdateChecker|Vcs\BaseChecker
  28. */
  29. public static function buildFromHeader($fullPath, $args = array()) {
  30. $fullPath = self::normalizePath($fullPath);
  31. //Set up defaults.
  32. $defaults = array(
  33. 'metadataUrl' => '',
  34. 'slug' => '',
  35. 'checkPeriod' => 12,
  36. 'optionName' => '',
  37. 'muPluginFile' => '',
  38. );
  39. $args = array_merge($defaults, array_intersect_key($args, $defaults));
  40. extract($args, EXTR_SKIP);
  41. //Check for the service URI
  42. if ( empty($metadataUrl) ) {
  43. $metadataUrl = self::getServiceURI($fullPath);
  44. }
  45. return self::buildUpdateChecker($metadataUrl, $fullPath, $slug, $checkPeriod, $optionName, $muPluginFile);
  46. }
  47. /**
  48. * Create a new instance of the update checker.
  49. *
  50. * This method automatically detects if you're using it for a plugin or a theme and chooses
  51. * the appropriate implementation for your update source (JSON file, GitHub, BitBucket, etc).
  52. *
  53. * @see UpdateChecker::__construct
  54. *
  55. * @param string $metadataUrl The URL of the metadata file, a GitHub repository, or another supported update source.
  56. * @param string $fullPath Full path to the main plugin file or to the theme directory.
  57. * @param string $slug Custom slug. Defaults to the name of the main plugin file or the theme directory.
  58. * @param int $checkPeriod How often to check for updates (in hours).
  59. * @param string $optionName Where to store bookkeeping info about update checks.
  60. * @param string $muPluginFile The plugin filename relative to the mu-plugins directory.
  61. * @return Plugin\UpdateChecker|Theme\UpdateChecker|Vcs\BaseChecker
  62. */
  63. public static function buildUpdateChecker($metadataUrl, $fullPath, $slug = '', $checkPeriod = 12, $optionName = '', $muPluginFile = '') {
  64. $fullPath = self::normalizePath($fullPath);
  65. $id = null;
  66. //Plugin or theme?
  67. $themeDirectory = self::getThemeDirectoryName($fullPath);
  68. if ( self::isPluginFile($fullPath) ) {
  69. $type = 'Plugin';
  70. $id = $fullPath;
  71. } else if ( $themeDirectory !== null ) {
  72. $type = 'Theme';
  73. $id = $themeDirectory;
  74. } else {
  75. throw new \RuntimeException(sprintf(
  76. 'The update checker cannot determine if "%s" is a plugin or a theme. ' .
  77. 'This is a bug. Please contact the PUC developer.',
  78. htmlentities($fullPath)
  79. ));
  80. }
  81. //Which hosting service does the URL point to?
  82. $service = self::getVcsService($metadataUrl);
  83. $apiClass = null;
  84. if ( empty($service) ) {
  85. //The default is to get update information from a remote JSON file.
  86. $checkerClass = $type . '\\UpdateChecker';
  87. } else {
  88. //You can also use a VCS repository like GitHub.
  89. $checkerClass = 'Vcs\\' . $type . 'UpdateChecker';
  90. $apiClass = $service . 'Api';
  91. }
  92. $checkerClass = self::getCompatibleClassVersion($checkerClass);
  93. if ( $checkerClass === null ) {
  94. //phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
  95. trigger_error(
  96. esc_html(sprintf(
  97. 'PUC %s does not support updates for %ss %s',
  98. self::$latestCompatibleVersion,
  99. strtolower($type),
  100. $service ? ('hosted on ' . $service) : 'using JSON metadata'
  101. )),
  102. E_USER_ERROR
  103. );
  104. }
  105. if ( !isset($apiClass) ) {
  106. //Plain old update checker.
  107. return new $checkerClass($metadataUrl, $id, $slug, $checkPeriod, $optionName, $muPluginFile);
  108. } else {
  109. //VCS checker + an API client.
  110. $apiClass = self::getCompatibleClassVersion($apiClass);
  111. if ( $apiClass === null ) {
  112. //phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
  113. trigger_error(esc_html(sprintf(
  114. 'PUC %s does not support %s',
  115. self::$latestCompatibleVersion,
  116. $service
  117. )), E_USER_ERROR);
  118. }
  119. return new $checkerClass(
  120. new $apiClass($metadataUrl),
  121. $id,
  122. $slug,
  123. $checkPeriod,
  124. $optionName,
  125. $muPluginFile
  126. );
  127. }
  128. }
  129. /**
  130. *
  131. * Normalize a filesystem path. Introduced in WP 3.9.
  132. * Copying here allows use of the class on earlier versions.
  133. * This version adapted from WP 4.8.2 (unchanged since 4.5.1)
  134. *
  135. * @param string $path Path to normalize.
  136. * @return string Normalized path.
  137. */
  138. public static function normalizePath($path) {
  139. if ( function_exists('wp_normalize_path') ) {
  140. return wp_normalize_path($path);
  141. }
  142. $path = str_replace('\\', '/', $path);
  143. $path = preg_replace('|(?<=.)/+|', '/', $path);
  144. if ( substr($path, 1, 1) === ':' ) {
  145. $path = ucfirst($path);
  146. }
  147. return $path;
  148. }
  149. /**
  150. * Check if the path points to a plugin file.
  151. *
  152. * @param string $absolutePath Normalized path.
  153. * @return bool
  154. */
  155. protected static function isPluginFile($absolutePath) {
  156. //Is the file inside the "plugins" or "mu-plugins" directory?
  157. $pluginDir = self::normalizePath(WP_PLUGIN_DIR);
  158. $muPluginDir = self::normalizePath(WPMU_PLUGIN_DIR);
  159. if ( (strpos($absolutePath, $pluginDir) === 0) || (strpos($absolutePath, $muPluginDir) === 0) ) {
  160. return true;
  161. }
  162. //Is it a file at all? Caution: is_file() can fail if the parent dir. doesn't have the +x permission set.
  163. if ( !is_file($absolutePath) ) {
  164. return false;
  165. }
  166. //Does it have a valid plugin header?
  167. //This is a last-ditch check for plugins symlinked from outside the WP root.
  168. if ( function_exists('get_file_data') ) {
  169. $headers = get_file_data($absolutePath, array('Name' => 'Plugin Name'), 'plugin');
  170. return !empty($headers['Name']);
  171. }
  172. return false;
  173. }
  174. /**
  175. * Get the name of the theme's directory from a full path to a file inside that directory.
  176. * E.g. "/abc/public_html/wp-content/themes/foo/whatever.php" => "foo".
  177. *
  178. * Note that subdirectories are currently not supported. For example,
  179. * "/xyz/wp-content/themes/my-theme/includes/whatever.php" => NULL.
  180. *
  181. * @param string $absolutePath Normalized path.
  182. * @return string|null Directory name, or NULL if the path doesn't point to a theme.
  183. */
  184. protected static function getThemeDirectoryName($absolutePath) {
  185. if ( is_file($absolutePath) ) {
  186. $absolutePath = dirname($absolutePath);
  187. }
  188. if ( file_exists($absolutePath . '/style.css') ) {
  189. return basename($absolutePath);
  190. }
  191. return null;
  192. }
  193. /**
  194. * Get the service URI from the file header.
  195. *
  196. * @param string $fullPath
  197. * @return string
  198. */
  199. private static function getServiceURI($fullPath) {
  200. //Look for the URI
  201. if ( is_readable($fullPath) ) {
  202. $seek = array(
  203. 'github' => 'GitHub URI',
  204. 'gitlab' => 'GitLab URI',
  205. 'bucket' => 'BitBucket URI',
  206. );
  207. $seek = apply_filters('puc_get_source_uri', $seek);
  208. $data = get_file_data($fullPath, $seek);
  209. foreach ($data as $key => $uri) {
  210. if ( $uri ) {
  211. return $uri;
  212. }
  213. }
  214. }
  215. //URI was not found so throw an error.
  216. throw new \RuntimeException(
  217. sprintf('Unable to locate URI in header of "%s"', htmlentities($fullPath))
  218. );
  219. }
  220. /**
  221. * Get the name of the hosting service that the URL points to.
  222. *
  223. * @param string $metadataUrl
  224. * @return string|null
  225. */
  226. private static function getVcsService($metadataUrl) {
  227. $service = null;
  228. //Which hosting service does the URL point to?
  229. $host = (string)(wp_parse_url($metadataUrl, PHP_URL_HOST));
  230. $path = (string)(wp_parse_url($metadataUrl, PHP_URL_PATH));
  231. //Check if the path looks like "/user-name/repository".
  232. //For GitLab.com it can also be "/user/group1/group2/.../repository".
  233. $repoRegex = '@^/?([^/]+?)/([^/#?&]+?)/?$@';
  234. if ( $host === 'gitlab.com' ) {
  235. $repoRegex = '@^/?(?:[^/#?&]++/){1,20}(?:[^/#?&]++)/?$@';
  236. }
  237. if ( preg_match($repoRegex, $path) ) {
  238. $knownServices = array(
  239. 'github.com' => 'GitHub',
  240. 'bitbucket.org' => 'BitBucket',
  241. 'gitlab.com' => 'GitLab',
  242. );
  243. if ( isset($knownServices[$host]) ) {
  244. $service = $knownServices[$host];
  245. }
  246. }
  247. return apply_filters('puc_get_vcs_service', $service, $host, $path, $metadataUrl);
  248. }
  249. /**
  250. * Get the latest version of the specified class that has the same major version number
  251. * as this factory class.
  252. *
  253. * @param string $class Partial class name.
  254. * @return string|null Full class name.
  255. */
  256. protected static function getCompatibleClassVersion($class) {
  257. if ( isset(self::$classVersions[$class][self::$latestCompatibleVersion]) ) {
  258. return self::$classVersions[$class][self::$latestCompatibleVersion];
  259. }
  260. return null;
  261. }
  262. /**
  263. * Get the specific class name for the latest available version of a class.
  264. *
  265. * @param string $class
  266. * @return null|string
  267. */
  268. public static function getLatestClassVersion($class) {
  269. if ( !self::$sorted ) {
  270. self::sortVersions();
  271. }
  272. if ( isset(self::$classVersions[$class]) ) {
  273. return reset(self::$classVersions[$class]);
  274. } else {
  275. return null;
  276. }
  277. }
  278. /**
  279. * Sort available class versions in descending order (i.e. newest first).
  280. */
  281. protected static function sortVersions() {
  282. foreach ( self::$classVersions as $class => $versions ) {
  283. uksort($versions, array(__CLASS__, 'compareVersions'));
  284. self::$classVersions[$class] = $versions;
  285. }
  286. self::$sorted = true;
  287. }
  288. protected static function compareVersions($a, $b) {
  289. return -version_compare($a, $b);
  290. }
  291. /**
  292. * Register a version of a class.
  293. *
  294. * @access private This method is only for internal use by the library.
  295. *
  296. * @param string $generalClass Class name without version numbers, e.g. 'PluginUpdateChecker'.
  297. * @param string $versionedClass Actual class name, e.g. 'PluginUpdateChecker_1_2'.
  298. * @param string $version Version number, e.g. '1.2'.
  299. */
  300. public static function addVersion($generalClass, $versionedClass, $version) {
  301. if ( empty(self::$myMajorVersion) ) {
  302. $lastNamespaceSegment = substr(__NAMESPACE__, strrpos(__NAMESPACE__, '\\') + 1);
  303. self::$myMajorVersion = substr(ltrim($lastNamespaceSegment, 'v'), 0, 1);
  304. }
  305. //Store the greatest version number that matches our major version.
  306. $components = explode('.', $version);
  307. if ( $components[0] === self::$myMajorVersion ) {
  308. if (
  309. empty(self::$latestCompatibleVersion)
  310. || version_compare($version, self::$latestCompatibleVersion, '>')
  311. ) {
  312. self::$latestCompatibleVersion = $version;
  313. }
  314. }
  315. if ( !isset(self::$classVersions[$generalClass]) ) {
  316. self::$classVersions[$generalClass] = array();
  317. }
  318. self::$classVersions[$generalClass][$version] = $versionedClass;
  319. self::$sorted = false;
  320. }
  321. }
  322. endif;