Passed
Push — main ( 44ea53...137754 )
by TARIQ
15:15 queued 02:39
created

GitHubApi::addHttpRequestFilter()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 5
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 8
rs 10
1
<?php
2
namespace YahnisElsts\PluginUpdateChecker\v5p0\Vcs;
3
4
use Parsedown;
5
6
if ( !class_exists(GitHubApi::class, false) ):
7
8
	class GitHubApi extends Api {
9
		/**
10
		 * @var string GitHub username.
11
		 */
12
		protected $userName;
13
		/**
14
		 * @var string GitHub repository name.
15
		 */
16
		protected $repositoryName;
17
18
		/**
19
		 * @var string Either a fully qualified repository URL, or just "user/repo-name".
20
		 */
21
		protected $repositoryUrl;
22
23
		/**
24
		 * @var string GitHub authentication token. Optional.
25
		 */
26
		protected $accessToken;
27
28
		/**
29
		 * @var bool Whether to download release assets instead of the auto-generated source code archives.
30
		 */
31
		protected $releaseAssetsEnabled = false;
32
33
		/**
34
		 * @var string|null Regular expression that's used to filter release assets by name. Optional.
35
		 */
36
		protected $assetFilterRegex = null;
37
38
		/**
39
		 * @var string|null The unchanging part of a release asset URL. Used to identify download attempts.
40
		 */
41
		protected $assetApiBaseUrl = null;
42
43
		/**
44
		 * @var bool
45
		 */
46
		private $downloadFilterAdded = false;
47
48
		public function __construct($repositoryUrl, $accessToken = null) {
49
			$path = wp_parse_url($repositoryUrl, PHP_URL_PATH);
50
			if ( preg_match('@^/?(?P<username>[^/]+?)/(?P<repository>[^/#?&]+?)/?$@', $path, $matches) ) {
51
				$this->userName = $matches['username'];
52
				$this->repositoryName = $matches['repository'];
53
			} else {
54
				throw new \InvalidArgumentException('Invalid GitHub repository URL: "' . $repositoryUrl . '"');
55
			}
56
57
			parent::__construct($repositoryUrl, $accessToken);
58
		}
59
60
		/**
61
		 * Get the latest release from GitHub.
62
		 *
63
		 * @return Reference|null
64
		 */
65
		public function getLatestRelease() {
66
			$release = $this->api('/repos/:user/:repo/releases/latest');
67
			if ( is_wp_error($release) || !is_object($release) || !isset($release->tag_name) ) {
68
				return null;
69
			}
70
71
			$reference = new Reference(array(
72
				'name'        => $release->tag_name,
73
				'version'     => ltrim($release->tag_name, 'v'), //Remove the "v" prefix from "v1.2.3".
74
				'downloadUrl' => $release->zipball_url,
75
				'updated'     => $release->created_at,
76
				'apiResponse' => $release,
77
			));
78
79
			if ( isset($release->assets[0]) ) {
80
				$reference->downloadCount = $release->assets[0]->download_count;
81
			}
82
83
			if ( $this->releaseAssetsEnabled && isset($release->assets, $release->assets[0]) ) {
84
				//Use the first release asset that matches the specified regular expression.
85
				$matchingAssets = array_filter($release->assets, array($this, 'matchesAssetFilter'));
86
				if ( !empty($matchingAssets) ) {
87
					if ( $this->isAuthenticationEnabled() ) {
88
						/**
89
						 * Keep in mind that we'll need to add an "Accept" header to download this asset.
90
						 *
91
						 * @see setUpdateDownloadHeaders()
92
						 */
93
						$reference->downloadUrl = $matchingAssets[0]->url;
94
					} else {
95
						//It seems that browser_download_url only works for public repositories.
96
						//Using an access_token doesn't help. Maybe OAuth would work?
97
						$reference->downloadUrl = $matchingAssets[0]->browser_download_url;
98
					}
99
100
					$reference->downloadCount = $matchingAssets[0]->download_count;
101
				}
102
			}
103
104
			if ( !empty($release->body) ) {
105
				$reference->changelog = Parsedown::instance()->text($release->body);
106
			}
107
108
			return $reference;
109
		}
110
111
		/**
112
		 * Get the tag that looks like the highest version number.
113
		 *
114
		 * @return Reference|null
115
		 */
116
		public function getLatestTag() {
117
			$tags = $this->api('/repos/:user/:repo/tags');
118
119
			if ( is_wp_error($tags) || !is_array($tags) ) {
120
				return null;
121
			}
122
123
			$versionTags = $this->sortTagsByVersion($tags);
124
			if ( empty($versionTags) ) {
125
				return null;
126
			}
127
128
			$tag = $versionTags[0];
129
			return new Reference(array(
130
				'name'        => $tag->name,
131
				'version'     => ltrim($tag->name, 'v'),
132
				'downloadUrl' => $tag->zipball_url,
133
				'apiResponse' => $tag,
134
			));
135
		}
136
137
		/**
138
		 * Get a branch by name.
139
		 *
140
		 * @param string $branchName
141
		 * @return null|Reference
142
		 */
143
		public function getBranch($branchName) {
144
			$branch = $this->api('/repos/:user/:repo/branches/' . $branchName);
145
			if ( is_wp_error($branch) || empty($branch) ) {
146
				return null;
147
			}
148
149
			$reference = new Reference(array(
150
				'name'        => $branch->name,
151
				'downloadUrl' => $this->buildArchiveDownloadUrl($branch->name),
152
				'apiResponse' => $branch,
153
			));
154
155
			if ( isset($branch->commit, $branch->commit->commit, $branch->commit->commit->author->date) ) {
156
				$reference->updated = $branch->commit->commit->author->date;
157
			}
158
159
			return $reference;
160
		}
161
162
		/**
163
		 * Get the latest commit that changed the specified file.
164
		 *
165
		 * @param string $filename
166
		 * @param string $ref Reference name (e.g. branch or tag).
167
		 * @return \StdClass|null
168
		 */
169
		public function getLatestCommit($filename, $ref = 'master') {
170
			$commits = $this->api(
171
				'/repos/:user/:repo/commits',
172
				array(
173
					'path' => $filename,
174
					'sha'  => $ref,
175
				)
176
			);
177
			if ( !is_wp_error($commits) && isset($commits[0]) ) {
178
				return $commits[0];
179
			}
180
			return null;
181
		}
182
183
		/**
184
		 * Get the timestamp of the latest commit that changed the specified branch or tag.
185
		 *
186
		 * @param string $ref Reference name (e.g. branch or tag).
187
		 * @return string|null
188
		 */
189
		public function getLatestCommitTime($ref) {
190
			$commits = $this->api('/repos/:user/:repo/commits', array('sha' => $ref));
191
			if ( !is_wp_error($commits) && isset($commits[0]) ) {
192
				return $commits[0]->commit->author->date;
193
			}
194
			return null;
195
		}
196
197
		/**
198
		 * Perform a GitHub API request.
199
		 *
200
		 * @param string $url
201
		 * @param array $queryParams
202
		 * @return mixed|\WP_Error
203
		 */
204
		protected function api($url, $queryParams = array()) {
205
			$baseUrl = $url;
206
			$url = $this->buildApiUrl($url, $queryParams);
207
208
			$options = array('timeout' => 10);
209
			if ( $this->isAuthenticationEnabled() ) {
210
				$options['headers'] = array('Authorization' => $this->getAuthorizationHeader());
211
			}
212
213
			if ( !empty($this->httpFilterName) ) {
214
				$options = apply_filters($this->httpFilterName, $options);
215
			}
216
			$response = wp_remote_get($url, $options);
217
			if ( is_wp_error($response) ) {
218
				do_action('puc_api_error', $response, null, $url, $this->slug);
219
				return $response;
220
			}
221
222
			$code = wp_remote_retrieve_response_code($response);
223
			$body = wp_remote_retrieve_body($response);
224
			if ( $code === 200 ) {
225
				$document = json_decode($body);
226
				return $document;
227
			}
228
229
			$error = new \WP_Error(
230
				'puc-github-http-error',
231
				sprintf('GitHub API error. Base URL: "%s",  HTTP status code: %d.', $baseUrl, $code)
232
			);
233
			do_action('puc_api_error', $error, $response, $url, $this->slug);
234
235
			return $error;
236
		}
237
238
		/**
239
		 * Build a fully qualified URL for an API request.
240
		 *
241
		 * @param string $url
242
		 * @param array $queryParams
243
		 * @return string
244
		 */
245
		protected function buildApiUrl($url, $queryParams) {
246
			$variables = array(
247
				'user' => $this->userName,
248
				'repo' => $this->repositoryName,
249
			);
250
			foreach ($variables as $name => $value) {
251
				$url = str_replace('/:' . $name, '/' . urlencode($value), $url);
252
			}
253
			$url = 'https://api.github.com' . $url;
254
255
			if ( !empty($queryParams) ) {
256
				$url = add_query_arg($queryParams, $url);
257
			}
258
259
			return $url;
260
		}
261
262
		/**
263
		 * Get the contents of a file from a specific branch or tag.
264
		 *
265
		 * @param string $path File name.
266
		 * @param string $ref
267
		 * @return null|string Either the contents of the file, or null if the file doesn't exist or there's an error.
268
		 */
269
		public function getRemoteFile($path, $ref = 'master') {
270
			$apiUrl = '/repos/:user/:repo/contents/' . $path;
271
			$response = $this->api($apiUrl, array('ref' => $ref));
272
273
			if ( is_wp_error($response) || !isset($response->content) || ($response->encoding !== 'base64') ) {
274
				return null;
275
			}
276
			return base64_decode($response->content);
277
		}
278
279
		/**
280
		 * Generate a URL to download a ZIP archive of the specified branch/tag/etc.
281
		 *
282
		 * @param string $ref
283
		 * @return string
284
		 */
285
		public function buildArchiveDownloadUrl($ref = 'master') {
286
			$url = sprintf(
287
				'https://api.github.com/repos/%1$s/%2$s/zipball/%3$s',
288
				urlencode($this->userName),
289
				urlencode($this->repositoryName),
290
				urlencode($ref)
291
			);
292
			return $url;
293
		}
294
295
		/**
296
		 * Get a specific tag.
297
		 *
298
		 * @param string $tagName
299
		 * @return void
300
		 */
301
		public function getTag($tagName) {
302
			//The current GitHub update checker doesn't use getTag, so I didn't bother to implement it.
303
			throw new \LogicException('The ' . __METHOD__ . ' method is not implemented and should not be used.');
304
		}
305
306
		public function setAuthentication($credentials) {
307
			parent::setAuthentication($credentials);
308
			$this->accessToken = is_string($credentials) ? $credentials : null;
309
310
			//Optimization: Instead of filtering all HTTP requests, let's do it only when
311
			//WordPress is about to download an update.
312
			add_filter('upgrader_pre_download', array($this, 'addHttpRequestFilter'), 10, 1); //WP 3.7+
313
		}
314
315
		protected function getUpdateDetectionStrategies($configBranch) {
316
			$strategies = array();
317
318
			if ( $configBranch === 'master' ) {
319
				//Use the latest release.
320
				$strategies[self::STRATEGY_LATEST_RELEASE] = array($this, 'getLatestRelease');
321
				//Failing that, use the tag with the highest version number.
322
				$strategies[self::STRATEGY_LATEST_TAG] = array($this, 'getLatestTag');
323
			}
324
325
			//Alternatively, just use the branch itself.
326
			$strategies[self::STRATEGY_BRANCH] = function() use ($configBranch) {
327
				return $this->getBranch($configBranch);
328
			};
329
330
			return $strategies;
331
		}
332
333
		/**
334
		 * Enable updating via release assets.
335
		 *
336
		 * If the latest release contains no usable assets, the update checker
337
		 * will fall back to using the automatically generated ZIP archive.
338
		 *
339
		 * Private repositories will only work with WordPress 3.7 or later.
340
		 *
341
		 * @param string|null $fileNameRegex Optional. Use only those assets where the file name matches this regex.
342
		 */
343
		public function enableReleaseAssets($fileNameRegex = null) {
344
			$this->releaseAssetsEnabled = true;
345
			$this->assetFilterRegex = $fileNameRegex;
346
			$this->assetApiBaseUrl = sprintf(
347
				'//api.github.com/repos/%1$s/%2$s/releases/assets/',
348
				$this->userName,
349
				$this->repositoryName
350
			);
351
		}
352
353
		/**
354
		 * Does this asset match the file name regex?
355
		 *
356
		 * @param \stdClass $releaseAsset
357
		 * @return bool
358
		 */
359
		protected function matchesAssetFilter($releaseAsset) {
360
			if ( $this->assetFilterRegex === null ) {
361
				//The default is to accept all assets.
362
				return true;
363
			}
364
			return isset($releaseAsset->name) && preg_match($this->assetFilterRegex, $releaseAsset->name);
365
		}
366
367
		/**
368
		 * @internal
369
		 * @param bool $result
370
		 * @return bool
371
		 */
372
		public function addHttpRequestFilter($result) {
373
			if ( !$this->downloadFilterAdded && $this->isAuthenticationEnabled() ) {
374
				//phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.http_request_args -- The callback doesn't change the timeout.
375
				add_filter('http_request_args', array($this, 'setUpdateDownloadHeaders'), 10, 2);
376
				add_action('requests-requests.before_redirect', array($this, 'removeAuthHeaderFromRedirects'), 10, 4);
377
				$this->downloadFilterAdded = true;
378
			}
379
			return $result;
380
		}
381
382
		/**
383
		 * Set the HTTP headers that are necessary to download updates from private repositories.
384
		 *
385
		 * See GitHub docs:
386
		 * @link https://developer.github.com/v3/repos/releases/#get-a-single-release-asset
387
		 * @link https://developer.github.com/v3/auth/#basic-authentication
388
		 *
389
		 * @internal
390
		 * @param array $requestArgs
391
		 * @param string $url
392
		 * @return array
393
		 */
394
		public function setUpdateDownloadHeaders($requestArgs, $url = '') {
395
			//Is WordPress trying to download one of our release assets?
396
			if ( $this->releaseAssetsEnabled && (strpos($url, $this->assetApiBaseUrl) !== false) ) {
397
				$requestArgs['headers']['Accept'] = 'application/octet-stream';
398
			}
399
			//Use Basic authentication, but only if the download is from our repository.
400
			$repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', array());
401
			if ( $this->isAuthenticationEnabled() && (strpos($url, $repoApiBaseUrl)) === 0 ) {
402
				$requestArgs['headers']['Authorization'] = $this->getAuthorizationHeader();
403
			}
404
			return $requestArgs;
405
		}
406
407
		/**
408
		 * When following a redirect, the Requests library will automatically forward
409
		 * the authorization header to other hosts. We don't want that because it breaks
410
		 * AWS downloads and can leak authorization information.
411
		 *
412
		 * @internal
413
		 * @param string $location
414
		 * @param array $headers
415
		 */
416
		public function removeAuthHeaderFromRedirects(&$location, &$headers) {
417
			$repoApiBaseUrl = $this->buildApiUrl('/repos/:user/:repo/', array());
418
			if ( strpos($location, $repoApiBaseUrl) === 0 ) {
419
				return; //This request is going to GitHub, so it's fine.
420
			}
421
			//Remove the header.
422
			if ( isset($headers['Authorization']) ) {
423
				unset($headers['Authorization']);
424
			}
425
		}
426
427
		/**
428
		 * Generate the value of the "Authorization" header.
429
		 *
430
		 * @return string
431
		 */
432
		protected function getAuthorizationHeader() {
433
			return 'Basic ' . base64_encode($this->userName . ':' . $this->accessToken);
434
		}
435
	}
436
437
endif;
438