updatepulse-server/lib/package-update-checker/Vcs/GitHubApi.php

591 lines
17 KiB
PHP

<?php // phpcs:ignore WordPress.Files.FileName.NotHyphenatedLowercase, WordPress.Files.FileName.InvalidClassFileName
namespace Anyape\PackageUpdateChecker\Vcs;
use WP_Error;
use InvalidArgumentException;
use LogicException;
if ( ! class_exists( GitHubApi::class, false ) ) :
/**
* Class GitHubApi
*
* This class provides methods to interact with the GitHub API for various operations
* such as fetching releases, tags, branches, and commits. It also handles authentication
* and API request construction.
*/
class GitHubApi extends Api {
use ReleaseAssetSupport;
use ReleaseFilteringFeature;
/**
* @var string GitHub authentication token. Optional.
*/
protected $access_token;
/**
* @var bool Indicates if the download filter has been added.
*/
private $download_filter_added = false;
/**
* GitHubApi constructor.
*
* @param string $repository_url The URL of the GitHub repository.
* @param string|null $access_token Optional GitHub access token.
* @throws InvalidArgumentException If the repository URL is invalid.
*/
public function __construct( $repository_url, $access_token = null ) {
$path = wp_parse_url( $repository_url, PHP_URL_PATH );
if ( preg_match( '@^/?(?P<username>[^/]+?)/(?P<repository>[^/#?&]+?)/?$@', $path, $matches ) ) {
$this->user_name = $matches['username'];
$this->repository_name = $matches['repository'];
} else {
throw new InvalidArgumentException(
esc_html( 'Invalid GitHub repository URL: "' . $repository_url . '"' )
);
}
parent::__construct( $repository_url, $access_token );
}
/**
* Check if the VCS is accessible.
*
* @param string $url The URL to check.
* @param string|null $access_token Optional GitHub access token.
* @return bool|WP_Error True if accessible, false or WP_Error otherwise.
*/
public static function test( $url, $access_token = null ) {
$instance = new self( $url . 'bogus/', $access_token );
$endpoint = 'https://api.github.com/user';
$response = $instance->api( $endpoint, array(), true );
if ( is_wp_error( $response ) ) {
return false;
}
if (
isset( $response->html_url ) &&
trailingslashit( $url ) === trailingslashit( $response->html_url )
) {
return true;
}
if ( ! isset( $response->login ) ) {
return false;
}
$endpoint = 'https://api.github.com/orgs/'
. rawurlencode( $instance->user_name )
. '/members/'
. rawurlencode( $response->login );
$response = $instance->api( $endpoint, array(), true );
if ( is_wp_error( $response ) ) {
return false;
}
$error = new WP_Error(
'puc-github-http-error',
sprintf( 'GitHub API error. Base URL: "%s", HTTP status code: %d.', $url, $response->code )
);
do_action( 'puc_api_error', $error, $response, $url, $instance->slug );
if ( 204 !== $response->code ) {
return 'failed_org_check';
}
return true;
}
/**
* Retrieve the latest release from GitHub.
*
* @return Reference|null The latest release or null if not found.
*/
public function get_latest_release() {
// The "latest release" endpoint returns one release and always skips pre-releases, so we can only use it if that's compatible with the current filter settings.
if (
$this->should_skip_pre_releases()
&& (
( 1 === $this->release_filter_max_releases ) || ! $this->has_custom_release_filter()
)
) {
// Fetch the latest release.
$release = $this->api( '/repos/:user/:repo/releases/latest' );
if ( is_wp_error( $release ) || ! is_object( $release ) || ! isset( $release->tag_name ) ) {
return null;
}
$found_releases = array( $release );
} else {
// Retrieve a list of the most recent releases.
$found_releases = $this->api(
'/repos/:user/:repo/releases',
array( 'per_page' => $this->release_filter_max_releases )
);
if ( is_wp_error( $found_releases ) || ! is_array( $found_releases ) ) {
return null;
}
}
foreach ( $found_releases as $release ) {
// Always skip drafts.
if ( isset( $release->draft ) && ! empty( $release->draft ) ) {
continue;
}
// Skip pre-releases unless specifically included.
if (
$this->should_skip_pre_releases()
&& isset( $release->prerelease )
&& ! empty( $release->prerelease )
) {
continue;
}
$version_number = ltrim( $release->tag_name, 'v' ); // Remove the "v" prefix from "v1.2.3".
// Custom release filtering.
if ( ! $this->matches_custom_release_filter( $version_number, $release ) ) {
continue;
}
$reference = new Reference(
array(
'name' => $release->tag_name,
'version' => $version_number,
'download_url' => $release->zipball_url,
'updated' => $release->created_at,
'apiResponse' => $release,
)
);
if ( isset( $release->assets[0] ) ) {
$reference->download_count = $release->assets[0]->download_count;
}
if ( $this->release_assets_enabled ) {
// Use the first release asset that matches the specified regular expression.
if ( isset( $release->assets, $release->assets[0] ) ) {
$matching_assets = array_values( array_filter( $release->assets, array( $this, 'matchesAssetFilter' ) ) );
} else {
$matching_assets = array();
}
if ( ! empty( $matching_assets ) ) {
if ( $this->is_authentication_enabled() ) {
/**
* Keep in mind that we'll need to add an "Accept" header to download this asset.
*
* @see set_update_download_headers()
*/
$reference->download_url = $matching_assets[0]->url;
} else {
// It seems that browser_download_url only works for public repositories.
// Using an access_token doesn't help. Maybe OAuth would work?
$reference->download_url = $matching_assets[0]->browser_download_url;
}
$reference->download_count = $matching_assets[0]->download_count;
} elseif ( Api::REQUIRE_RELEASE_ASSETS === $this->release_asset_preference ) {
// None of the assets match the filter, and we're not allowed to fall back to the auto-generated source ZIP.
return null;
}
}
return $reference;
}
return null;
}
/**
* Retrieve the tag that appears to be the highest version number.
*
* @return Reference|null The highest version tag or null if not found.
*/
public function get_latest_tag() {
$tags = $this->api( '/repos/:user/:repo/tags' );
if ( is_wp_error( $tags ) || ! is_array( $tags ) ) {
return null;
}
$version_tags = $this->sort_tags_by_version( $tags );
if ( empty( $version_tags ) ) {
return null;
}
$tag = $version_tags[0];
return new Reference(
array(
'name' => $tag->name,
'version' => ltrim( $tag->name, 'v' ),
'download_url' => $tag->zipball_url,
'apiResponse' => $tag,
)
);
}
/**
* Retrieve a branch by its name.
*
* @param string $branch_name The name of the branch.
* @return null|Reference The branch reference or null if not found.
*/
public function get_branch( $branch_name ) {
$branch = $this->api( '/repos/:user/:repo/branches/' . $branch_name );
if ( is_wp_error( $branch ) || empty( $branch ) ) {
return null;
}
$reference = new Reference(
array(
'name' => $branch->name,
'download_url' => $this->build_archive_download_url( $branch->name ),
'apiResponse' => $branch,
)
);
if ( isset( $branch->commit, $branch->commit->commit, $branch->commit->commit->author->date ) ) {
$reference->updated = $branch->commit->commit->author->date;
}
return $reference;
}
/**
* Retrieve the latest commit that modified the specified file.
*
* @param string $filename The name of the file.
* @param string $ref Reference name (e.g., branch or tag).
* @return \StdClass|null The latest commit or null if not found.
*/
public function get_latest_commit( $filename, $ref = 'main' ) {
$commits = $this->api(
'/repos/:user/:repo/commits',
array(
'path' => $filename,
'sha' => $ref,
)
);
if ( ! is_wp_error( $commits ) && isset( $commits[0] ) ) {
return $commits[0];
}
return null;
}
/**
* Retrieve the timestamp of the latest commit that modified the specified branch or tag.
*
* @param string $ref Reference name (e.g., branch or tag).
* @return string|null The timestamp of the latest commit or null if not found.
*/
public function get_latest_commit_time( $ref ) {
$commits = $this->api( '/repos/:user/:repo/commits', array( 'sha' => $ref ) );
if ( ! is_wp_error( $commits ) && isset( $commits[0] ) ) {
return $commits[0]->commit->author->date;
}
return null;
}
/**
* Perform a GitHub API request.
*
* @param string $url The API endpoint URL.
* @param array $query_params Optional query parameters.
* @param bool $override_url Whether to override the base URL.
* @return mixed|WP_Error The API response or WP_Error on failure.
*/
protected function api( $url, $query_params = array(), $override_url = false ) {
$base_url = $url;
if ( ! $override_url ) {
$url = $this->build_api_url( $url, $query_params );
}
$options = array( 'timeout' => wp_doing_cron() ? 10 : 3 );
if ( $this->is_authentication_enabled() ) {
$options['headers'] = $this->get_authorization_headers();
}
$response = wp_remote_get( $url, $options );
if ( is_wp_error( $response ) ) {
do_action( 'puc_api_error', $response, null, $url, $this->slug );
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
$body = wp_remote_retrieve_body( $response );
if ( 200 === $code ) {
return json_decode( $body );
}
if ( $override_url ) {
$response = json_decode( $body );
if ( ! is_object( $response ) ) {
$response = new \StdClass();
}
$response->code = $code;
return $response;
}
$error = new WP_Error(
'puc-github-http-error',
sprintf( 'GitHub API error. Base URL: "%s", HTTP status code: %d.', $base_url, $code )
);
do_action( 'puc_api_error', $error, $response, $url, $this->slug );
return $error;
}
/**
* Construct a fully qualified URL for an API request.
*
* @param string $url The API endpoint URL.
* @param array $query_params Optional query parameters.
* @return string The fully qualified URL.
*/
protected function build_api_url( $url, $query_params ) {
$variables = array(
'user' => $this->user_name,
'repo' => $this->repository_name,
);
foreach ( $variables as $name => $value ) {
$url = str_replace( '/:' . $name, '/' . rawurlencode( $value ), $url );
}
$url = 'https://api.github.com' . $url;
if ( ! empty( $query_params ) ) {
$url = add_query_arg( $query_params, $url );
}
return $url;
}
/**
* Retrieve the contents of a file from a specific branch or tag.
*
* @param string $path The file path.
* @param string $ref The reference name (e.g., branch or tag).
* @return null|string The file contents or null if not found.
*/
public function get_remote_file( $path, $ref = 'main' ) {
$api_url = '/repos/:user/:repo/contents/' . $path;
$response = $this->api( $api_url, array( 'ref' => $ref ) );
if ( is_wp_error( $response ) || ! isset( $response->content ) || ( 'base64' !== $response->encoding ) ) {
return null;
}
return base64_decode( $response->content ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
}
/**
* Generate a URL to download a ZIP archive of the specified branch/tag/etc.
*
* @param string $ref The reference name (e.g., branch or tag).
* @return string The download URL.
*/
public function build_archive_download_url( $ref = 'main' ) {
$url = sprintf(
'https://api.github.com/repos/%1$s/%2$s/zipball/%3$s',
rawurlencode( $this->user_name ),
rawurlencode( $this->repository_name ),
rawurlencode( $ref )
);
return $url;
}
/**
* Retrieve a specific tag.
*
* @param string $tag_name The name of the tag.
* @return void
* @throws LogicException If the method is not implemented.
*/
public function get_tag( $tag_name ) {
// The current GitHub update checker doesn't use get_tag, so I didn't bother to implement it.
throw new LogicException( 'The ' . __METHOD__ . ' method is not implemented and should not be used.' );
}
/**
* Set the authentication credentials.
*
* @param string|array $credentials The authentication credentials.
*/
public function set_authentication( $credentials ) {
parent::set_authentication( $credentials );
$this->access_token = is_string( $credentials ) ? $credentials : null;
}
/**
* Retrieve the update detection strategies based on the configuration branch.
*
* @param string $config_branch The configuration branch.
* @return array The update detection strategies.
*/
protected function get_update_detection_strategies( $config_branch ) {
$strategies = array();
if (
( 'main' === $config_branch || 'master' === $config_branch ) &&
( ! defined( 'PUC_FORCE_BRANCH' ) || ! (bool) ( constant( 'PUC_FORCE_BRANCH' ) ) )
) {
// Use the latest release.
$strategies[ self::STRATEGY_LATEST_RELEASE ] = array( $this, 'get_latest_release' );
// Failing that, use the tag with the highest version number.
$strategies[ self::STRATEGY_LATEST_TAG ] = array( $this, 'get_latest_tag' );
}
// Alternatively, just use the branch itself.
$strategies[ self::STRATEGY_BRANCH ] = function () use ( $config_branch ) {
return $this->get_branch( $config_branch );
};
return $strategies;
}
/**
* Retrieve the unchanging part of a release asset URL. Used to identify download attempts.
*
* @return string The base URL for release assets.
*/
protected function get_asset_api_base_url() {
return sprintf(
'//api.github.com/repos/%1$s/%2$s/releases/assets/',
$this->user_name,
$this->repository_name
);
}
/**
* Retrieve the filterable name of a release asset.
*
* @param object $release_asset The release asset object.
* @return string|null The name of the release asset or null if not found.
*/
protected function get_filterable_asset_name( $release_asset ) {
if ( isset( $release_asset->name ) ) {
return $release_asset->name;
}
return null;
}
/**
* Add an HTTP request filter.
*
* @param bool $result The result of the filter.
* @return bool The result of the filter.
* @internal
*/
public function add_http_request_filter( $result ) {
if ( ! $this->download_filter_added && $this->is_authentication_enabled() ) {
//phpcs:ignore WordPressVIPMinimum.Hooks.RestrictedHooks.http_request_args -- The callback doesn't change the timeout.
add_filter( 'http_request_args', array( $this, 'set_update_download_headers' ), 10, 2 );
add_action( 'requests-requests.before_redirect', array( $this, 'remove_auth_header_from_redirects' ), 10, 4 );
$this->download_filter_added = true;
}
return $result;
}
/**
* Set the HTTP headers required to download updates from private repositories.
*
* Refer to GitHub documentation:
*
* @link https://developer.github.com/v3/repos/releases/#get-a-single-release-asset
* @link https://developer.github.com/v3/auth/#basic-authentication
*
* @internal
* @param array $requestArgs
* @param string $url
* @return array
*/
public function set_update_download_headers( $request_args, $url = '' ) {
// Check if WordPress is attempting to download one of our release assets.
if ( $this->release_assets_enabled && ( strpos( $url, $this->get_asset_api_base_url() ) !== false ) ) {
$request_args['headers']['Accept'] = 'application/octet-stream';
}
// Use Basic authentication only if the download is from our repository.
$repo_api_base_url = $this->build_api_url( '/repos/:user/:repo/', array() );
if ( $this->is_authentication_enabled() && ( strpos( $url, $repo_api_base_url ) ) === 0 ) {
$request_args['headers'] = array_merge( $request_args['headers'], $this->get_authorization_headers() );
}
return $request_args;
}
/**
* When following a redirect, the Requests library will automatically forward
* the authorization header to other hosts. This can cause issues with AWS downloads
* and may expose authorization information.
*
* @param string $location
* @param array $headers
* @internal
*/
public function remove_auth_header_from_redirects( &$location, &$headers ) {
$repo_api_base_url = $this->build_api_url( '/repos/:user/:repo/', array() );
if ( strpos( $location, $repo_api_base_url ) === 0 ) {
return; // This request is going to GitHub, so it's acceptable.
}
// Remove the authorization header.
if ( isset( $headers['Authorization'] ) ) {
unset( $headers['Authorization'] );
}
}
/**
* Create the value for the "Authorization" header.
*
* @return string
*/
public function get_authorization_headers() {
return array(
'Authorization' => 'Basic ' . base64_encode( $this->user_name . ':' . $this->access_token ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
);
}
}
endif;