Files
marcus 51ccabc24e
CI / Lint (PHP 7.4) (push) Canceled after 0s
CI / Release build, preflight and Plugin Check (push) Canceled after 0s
CI / Smoke test (WordPress 6.2, PHP 7.4) (push) Canceled after 0s
CI / Smoke test (WordPress latest, PHP 8.3) (push) Canceled after 0s
fix: GitHub updater requests never follow redirects, whatever other plugins set
HTTP Requests Manager (blocking modes) sets every request's redirection to 1 on http_request_args. The updater's no-redirect requests then followed GitHub's redirect: signed_address() lost the Location, so private updates failed (and the token went to the download host), and fetch_web()'s HEAD on releases/latest got 200 instead of a redirect. remote_no_redirect() holds redirection at 0 for that one URL at PHP_INT_MAX. Updater copy 1.0.1 (the GH#261 change had not raised it).
2026-10-03 16:44:42 +01:00

829 lines
35 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Shared GitHub updater: updates from GitHub releases for every installed
* plugin that names its GitHub repository.
*
* Loaded by load.php, which picks the newest copy on the site; see there.
* Only in builds made from GitHub releases: the WordPress.org build leaves
* this folder out.
*
* - A plugin takes part by naming its repository in its main file's header:
* `GitHub Plugin URI: owner/repo` (the header Git Updater reads, so the
* two stay compatible). `Release Asset: true` means "install only the zip
* attached to the release, never the source code".
* - WordPress does the rest itself: this only tells it that a newer release
* exists and where its zip is. The Updates screen, auto-updates, "View
* details", the download, the install and the rollback on failure are
* WordPress's own. Nothing is removed from or blocked in its update check.
* - The latest release of each repository is asked for at most every 12
* hours (an hour after a failure), when WordPress checks for updates, and
* again when someone presses "Check again" on the Updates screen.
* - Public repositories are read from github.com's own pages (the
* releases/latest redirect, the asset's download address and the main
* file on raw.githubusercontent.com), not the API: without a token the API
* allows 60 requests an hour per server address, shared by every site on
* a host. The asset must be named {folder}-{version}.zip.
* - Private repositories need a GitHub token in wp-config.php
* (WPALLSTARS_GITHUB_TOKEN) or from the `wpallstars_github_token` filter,
* and are read through the API. The token is sent only to api.github.com,
* never stored and never shown.
* - Plugins also on WordPress.org keep updating from there unless the
* `wpallstars_github_updater_early` filter returns true.
* - While Git Updater is active, this waits and Git Updater does the job.
* The `wpallstars_github_updater_enabled` filter can turn it off too.
*
* @package WPStarter
*/
if (!defined('ABSPATH')) {
exit;
}
final class WPAllStars_GitHub_Updater {
/** Site transient: latest release of each repository, keyed by owner/repo. */
const CACHE = 'wpallstars_github_releases';
/** How long a release answer and a failed request are kept. */
const FRESH = 12 * HOUR_IN_SECONDS;
const RETRY = HOUR_IN_SECONDS;
const RECENT = MINUTE_IN_SECONDS;
/** Update IDs this file adds, so its own entries can be told apart. */
const ID_PREFIX = 'github.com/';
/**
* Release answers read this request.
*
* @var array|null
*/
private static $cache = null;
/**
* Register hooks (plugins_loaded). Whether to run is decided on init,
* once plugins have added their filters.
*/
public static function load() {
add_action('init', array(__CLASS__, 'boot'), 20);
}
/**
* Register the update hooks, unless switched off or Git Updater is active.
*/
public static function boot() {
/**
* Whether to add GitHub releases to WordPress's update check.
*
* @param bool $enabled False while Git Updater is active.
*/
if (!apply_filters('wpallstars_github_updater_enabled', !self::git_updater_active())) {
return;
}
// Update checks run in the admin, in cron and in WP-CLI.
add_filter('pre_set_site_transient_update_plugins', array(__CLASS__, 'add_updates'));
add_filter('plugins_api', array(__CLASS__, 'plugin_information'), 20, 3);
add_filter('upgrader_package_options', array(__CLASS__, 'private_package'));
add_filter('upgrader_pre_download', array(__CLASS__, 'private_download'), 10, 3);
add_filter('upgrader_source_selection', array(__CLASS__, 'fix_folder'), 10, 4);
}
/**
* Whether Git Updater is active on this site or network-wide.
*
* @return bool
*/
private static function git_updater_active() {
$files = (array) get_option('active_plugins', array());
if (is_multisite()) {
$files = array_merge($files, array_keys((array) get_site_option('active_sitewide_plugins', array())));
}
foreach ($files as $file) {
if ('git-updater' === dirname((string) $file)) {
return true;
}
}
return false;
}
/**
* Installed plugins that name a GitHub repository.
*
* @return array<string,array{repo:string,asset_only:bool,version:string,name:string}> Plugin file => details.
*/
public static function plugins() {
static $found = null;
if (null !== $found) {
return $found;
}
if (!function_exists('get_plugins')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$found = array();
foreach (get_plugins() as $file => $data) {
// Plugins in a folder only: an update replaces the whole folder.
if ('.' === dirname($file)) {
continue;
}
$headers = get_file_data(WP_PLUGIN_DIR . '/' . $file, array(
'repo' => 'GitHub Plugin URI',
'asset' => 'Release Asset',
));
$repo = self::repo_name($headers['repo']);
if ('' === $repo) {
continue;
}
$found[$file] = array(
'repo' => $repo,
'asset_only' => in_array(strtolower(trim($headers['asset'])), array('true', 'yes', '1'), true),
'version' => isset($data['Version']) ? (string) $data['Version'] : '',
'name' => isset($data['Name']) ? (string) $data['Name'] : $file,
);
}
/**
* Filter the plugins updated from GitHub releases.
*
* @param array $found Plugin file => array(repo, asset_only, version, name).
*/
$filtered = (array) apply_filters('wpallstars_github_plugins', $found);
$found = array();
foreach ($filtered as $file => $plugin) {
$repo = is_array($plugin) && isset($plugin['repo']) ? self::repo_name($plugin['repo']) : '';
if ('' === $repo || '.' === dirname((string) $file)) {
continue;
}
$found[(string) $file] = array(
'repo' => $repo,
'asset_only' => !empty($plugin['asset_only']),
'version' => isset($plugin['version']) ? (string) $plugin['version'] : '',
'name' => isset($plugin['name']) ? (string) $plugin['name'] : (string) $file,
);
}
return $found;
}
/**
* owner/repo from a header value ("owner/repo" or a github.com address).
*
* @param mixed $value Header value.
* @return string owner/repo, or '' when it is not one.
*/
public static function repo_name($value) {
$value = trim((string) $value);
$value = preg_replace('#^(?:https?://)?(?:www\.)?github\.com/#i', '', $value);
$value = preg_replace('#(?:\.git)?/*$#', '', (string) $value);
return preg_match('#^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/[A-Za-z0-9._-]{1,100}$#', (string) $value) ? (string) $value : '';
}
/**
* Token for a repository, if the site has one.
*
* @param string $repo owner/repo.
* @return string
*/
private static function token($repo) {
$token = defined('WPALLSTARS_GITHUB_TOKEN') ? (string) WPALLSTARS_GITHUB_TOKEN : '';
/**
* Filter the GitHub token used for a repository (private repositories
* need one with read access to its contents). Return '' for none.
*
* @param string $token Token.
* @param string $repo owner/repo.
*/
return trim((string) apply_filters('wpallstars_github_token', $token, $repo));
}
/**
* GET from the GitHub API.
*
* @param string $repo owner/repo (for the token).
* @param string $path Path after /repos/{repo}.
* @param string $accept Accept header.
* @return array|WP_Error
*/
private static function api_get($repo, $path, $accept = 'application/vnd.github+json') {
$headers = array(
'Accept' => $accept,
'X-GitHub-Api-Version' => '2022-11-28',
);
$token = self::token($repo);
if ('' !== $token) {
$headers['Authorization'] = 'Bearer ' . $token;
}
// No redirects: the token must never travel to another address.
return self::remote_no_redirect('GET', 'https://api.github.com/repos/' . $repo . $path, array(
'timeout' => 10,
'headers' => $headers,
));
}
/**
* A request that never follows a redirect, so the answer's Location can
* be read and a token never travels to another address.
*
* Some plugins raise every request's redirection count (HTTP Requests
* Manager sets it to 1), which would follow GitHub's redirect: the token
* would go along and the Location would be lost. Only this request is
* held at 0, after every other filter.
*
* @param string $method GET or HEAD.
* @param string $url Address.
* @param array $args Request arguments.
* @return array|WP_Error
*/
private static function remote_no_redirect($method, $url, array $args) {
$args['redirection'] = 0;
$hold = function ($parsed_args, $request_url) use ($url) {
if ($request_url === $url && is_array($parsed_args)) {
$parsed_args['redirection'] = 0;
}
return $parsed_args;
};
add_filter('http_request_args', $hold, PHP_INT_MAX, 2);
$response = 'HEAD' === $method ? wp_safe_remote_head($url, $args) : wp_safe_remote_get($url, $args);
remove_filter('http_request_args', $hold, PHP_INT_MAX);
return $response;
}
/**
* Stored release answers.
*
* @return array
*/
private static function cache() {
if (null === self::$cache) {
$stored = get_site_transient(self::CACHE);
self::$cache = is_array($stored) ? $stored : array();
}
return self::$cache;
}
/**
* Whether someone pressed "Check again" on the Updates screen.
*
* @return bool
*/
private static function forced() {
global $pagenow;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- core's own link; it only refreshes data.
return is_admin() && 'update-core.php' === $pagenow && !empty($_GET['force-check']) && current_user_can('update_plugins');
}
/**
* Latest release of a repository, from the cache or GitHub.
*
* @param string $repo owner/repo.
* @param string $folder Plugin folder (picks the release asset).
* @param string $main Main file name in the folder (reads its requirements).
* @return array|null Release, or null when there is none or GitHub did not answer.
*/
public static function release($repo, $folder, $main) {
$cache = self::cache();
// The asset and requirements depend on the plugin, not only the repository.
$key = $repo . '|' . $folder . '/' . $main;
$entry = isset($cache[$key]) && is_array($cache[$key]) ? $cache[$key] : null;
$age = $entry && isset($entry['checked']) ? time() - (int) $entry['checked'] : PHP_INT_MAX;
$keep = $entry && !empty($entry['failed']) ? self::RETRY : self::FRESH;
if ($entry && $age < $keep && !(self::forced() && $age > self::RECENT)) {
return isset($entry['release']) ? $entry['release'] : null;
}
$release = self::fetch($repo, $folder, $main);
if (is_wp_error($release)) {
// Keep the last good answer, and try again in an hour.
$entry = array(
'checked' => time(),
'failed' => $release->get_error_message(),
'release' => $entry && isset($entry['release']) ? $entry['release'] : null,
);
} else {
$entry = array('checked' => time(), 'release' => $release);
}
self::$cache[$key] = $entry;
set_site_transient(self::CACHE, self::$cache, 2 * DAY_IN_SECONDS);
return $entry['release'];
}
/**
* Ask GitHub for a repository's latest release.
*
* @param string $repo owner/repo.
* @param string $folder Plugin folder.
* @param string $main Main file name.
* @return array|null|WP_Error Release, null when there is no usable one, or the error.
*/
private static function fetch($repo, $folder, $main) {
return '' !== self::token($repo) ? self::fetch_api($repo, $folder, $main) : self::fetch_web($repo, $folder, $main);
}
/**
* An error for a GitHub answer that was not the expected one.
*
* @param int $code HTTP status code.
* @return WP_Error
*/
private static function status_error($code) {
/* translators: %d: HTTP status code */
return new WP_Error('wpallstars_github', sprintf(__('GitHub answered with status %d.', 'wp-plugin-starter-template'), $code));
}
/**
* Latest release from github.com's own pages, for public repositories.
* The GitHub API allows 60 requests an hour per server address without
* a token, which hosts share between many sites; these pages do not
* count towards it.
*
* @param string $repo owner/repo.
* @param string $folder Plugin folder.
* @param string $main Main file name.
* @return array|null|WP_Error
*/
private static function fetch_web($repo, $folder, $main) {
// Redirects to the newest release that is not a draft or pre-release.
$response = self::remote_no_redirect('HEAD', 'https://github.com/' . $repo . '/releases/latest', array('timeout' => 10));
if (is_wp_error($response)) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code($response);
$location = (string) wp_remote_retrieve_header($response, 'location');
if (404 === $code) {
// No such public repository (private ones need a token).
return null;
}
if ($code < 300 || $code > 399) {
return self::status_error($code);
}
if (!preg_match('#^https://github\.com/' . preg_quote($repo, '#') . '/releases/tag/([^/?\#]+)$#i', $location, $match)) {
// No releases yet: GitHub sends the releases list instead.
return null;
}
$tag = rawurldecode($match[1]);
$version = preg_replace('/^v/i', '', $tag);
if (!preg_match('/^[0-9]+(\.[0-9]+)*$/', $version)) {
return null;
}
// The release zip, by its usual name: {folder}-{version}.zip.
$asset = null;
$download = 'https://github.com/' . $repo . '/releases/download/' . rawurlencode($tag) . '/' . rawurlencode($folder . '-' . $version . '.zip');
$head = self::remote_no_redirect('HEAD', $download, array('timeout' => 10));
if (is_wp_error($head)) {
return $head;
}
$code = (int) wp_remote_retrieve_response_code($head);
if ($code >= 300 && $code < 400) {
$asset = array('public' => $download, 'api' => '');
} elseif (404 !== $code) {
return self::status_error($code);
}
$release = array(
'tag' => $tag,
'version' => $version,
'url' => $location,
'published' => '',
'notes' => '',
'asset' => $asset,
'zipball' => 'https://github.com/' . $repo . '/archive/refs/tags/' . rawurlencode($tag) . '.zip',
);
$file = wp_safe_remote_get('https://raw.githubusercontent.com/' . $repo . '/' . rawurlencode($tag) . '/' . rawurlencode($main), array(
'timeout' => 10,
'headers' => array('Range' => 'bytes=0-8191'),
));
$body = !is_wp_error($file) && in_array((int) wp_remote_retrieve_response_code($file), array(200, 206), true) ? (string) wp_remote_retrieve_body($file) : '';
return $release + self::requirements($body);
}
/**
* Latest release from the GitHub API, with a token (private repositories).
*
* @param string $repo owner/repo.
* @param string $folder Plugin folder.
* @param string $main Main file name.
* @return array|null|WP_Error
*/
private static function fetch_api($repo, $folder, $main) {
// The newest release that is not a draft or pre-release.
$response = self::api_get($repo, '/releases/latest');
if (is_wp_error($response)) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code($response);
if (404 === $code) {
// No releases yet, or a private repository without a token.
return null;
}
if (200 !== $code) {
return self::status_error($code);
}
$data = json_decode(wp_remote_retrieve_body($response), true);
$tag = is_array($data) && isset($data['tag_name']) ? (string) $data['tag_name'] : '';
$version = preg_replace('/^v/i', '', $tag);
// Numbers only: anything else is not a finished version.
if (!preg_match('/^[0-9]+(\.[0-9]+)*$/', $version)) {
return null;
}
$release = array(
'tag' => $tag,
'version' => $version,
'url' => isset($data['html_url']) ? (string) $data['html_url'] : 'https://github.com/' . $repo . '/releases',
'published' => isset($data['published_at']) ? (string) $data['published_at'] : '',
'notes' => isset($data['body']) ? substr((string) $data['body'], 0, 20000) : '',
'asset' => self::pick_asset($repo, $folder, isset($data['assets']) && is_array($data['assets']) ? $data['assets'] : array()),
'zipball' => 'https://api.github.com/repos/' . $repo . '/zipball/' . rawurlencode($tag),
);
$file = self::api_get($repo, '/contents/' . rawurlencode($main) . '?ref=' . rawurlencode($tag), 'application/vnd.github.raw+json');
$body = !is_wp_error($file) && 200 === (int) wp_remote_retrieve_response_code($file) ? (string) wp_remote_retrieve_body($file) : '';
return $release + self::requirements($body);
}
/**
* The release zip for a plugin folder: a .zip asset whose name starts
* with the folder name (never the "wordpress-org-…" build).
*
* @param string $repo owner/repo.
* @param string $folder Plugin folder.
* @param array $assets Release assets.
* @return array{public:string,api:string}|null Download addresses.
*/
private static function pick_asset($repo, $folder, array $assets) {
foreach ($assets as $asset) {
$name = isset($asset['name']) ? (string) $asset['name'] : '';
if (0 !== stripos($name, $folder) || '.zip' !== strtolower(substr($name, -4))) {
continue;
}
$public = isset($asset['browser_download_url']) ? (string) $asset['browser_download_url'] : '';
$api = isset($asset['id']) ? 'https://api.github.com/repos/' . $repo . '/releases/assets/' . (int) $asset['id'] : '';
if (0 === strpos($public, 'https://github.com/' . $repo . '/releases/download/')) {
return array('public' => $public, 'api' => $api);
}
}
return null;
}
/**
* "Requires at least" and "Requires PHP" of the released main file, so
* WordPress does not offer an update the site cannot run.
*
* @param string $file Start of the released main file ('' when unknown).
* @return array{requires:string,requires_php:string}
*/
private static function requirements($file) {
$found = array('requires' => '', 'requires_php' => '');
$head = substr((string) $file, 0, 8192);
foreach (array('requires' => 'Requires at least', 'requires_php' => 'Requires PHP') as $key => $header) {
if (preg_match('/^(?:[ \t]*<\?php)?[ \t\/*#@]*' . preg_quote($header, '/') . ':(.*)$/mi', $head, $match)) {
$value = trim(preg_replace('/\s*(?:\*\/|\?>).*/', '', $match[1]));
if (preg_match('/^[0-9]+(\.[0-9]+)*$/', $value)) {
$found[$key] = $value;
}
}
}
return $found;
}
/**
* Download address WordPress installs from.
*
* @param string $repo owner/repo.
* @param array $release Release.
* @param bool $asset_only Install the release asset only.
* @return string '' when the release has nothing to install.
*/
private static function package($repo, array $release, $asset_only) {
$private = '' !== self::token($repo);
if (!empty($release['asset'])) {
// Assets of private repositories download through the API (see private_package()).
return $private && $release['asset']['api'] ? $release['asset']['api'] : $release['asset']['public'];
}
return $asset_only ? '' : (string) $release['zipball'];
}
/**
* Whether WordPress.org offers updates for a plugin.
*
* @param object $transient update_plugins.
* @param string $file Plugin file.
* @return bool
*/
private static function on_wordpress_org($transient, $file) {
foreach (array('response', 'no_update') as $list) {
if (isset($transient->{$list}[$file])) {
$item = (object) $transient->{$list}[$file];
$id = isset($item->id) ? (string) $item->id : '';
if (0 !== strpos($id, self::ID_PREFIX)) {
return true;
}
}
}
return false;
}
/**
* Tell WordPress about GitHub releases when it saves its update check.
*
* @param mixed $transient update_plugins.
* @return mixed
*/
public static function add_updates($transient) {
if (!is_object($transient)) {
return $transient;
}
/**
* Whether plugins also on WordPress.org take each version from GitHub
* as soon as it is released, instead of waiting for WordPress.org.
*
* @param bool $early Default false.
*/
$early = (bool) apply_filters('wpallstars_github_updater_early', false);
foreach (self::plugins() as $file => $plugin) {
if (!$early && self::on_wordpress_org($transient, $file)) {
continue;
}
$folder = dirname($file);
$release = self::release($plugin['repo'], $folder, basename($file));
$current = isset($transient->checked[$file]) ? (string) $transient->checked[$file] : $plugin['version'];
if (!$release) {
// Keep WordPress.org's answer, or say nothing.
continue;
}
$dot_org = isset($transient->response[$file]->new_version) ? (string) $transient->response[$file]->new_version : '';
if ('' !== $dot_org && 0 !== strpos((string) ($transient->response[$file]->id ?? ''), self::ID_PREFIX) && version_compare($dot_org, $release['version'], '>=')) {
// WordPress.org already offers this version or a newer one.
continue;
}
$item = (object) array(
'id' => self::ID_PREFIX . $plugin['repo'],
'slug' => $folder,
'plugin' => $file,
'new_version' => $release['version'],
'url' => 'https://github.com/' . $plugin['repo'],
'package' => self::package($plugin['repo'], $release, $plugin['asset_only']),
'requires' => $release['requires'],
'requires_php' => $release['requires_php'],
'tested' => '',
'icons' => array(),
'banners' => array(),
'banners_rtl' => array(),
'compatibility' => new stdClass(),
);
if (!is_array($transient->response ?? null)) {
$transient->response = array();
}
if (!is_array($transient->no_update ?? null)) {
$transient->no_update = array();
}
if ('' === $item->package && self::on_wordpress_org($transient, $file)) {
// Nothing to install from GitHub: keep WordPress.org's answer.
continue;
}
unset($transient->response[$file], $transient->no_update[$file]);
if ('' !== $item->package && version_compare($release['version'], $current, '>')) {
$transient->response[$file] = $item;
} else {
// Listed as up to date, so the Plugins screen offers auto-updates.
$transient->no_update[$file] = $item;
}
}
return $transient;
}
/**
* The plugin a "View details" request is about, if it is ours to answer.
*
* @param string $slug Plugin folder.
* @return string Plugin file, or ''.
*/
private static function file_for_slug($slug) {
$updates = get_site_transient('update_plugins');
foreach (self::plugins() as $file => $plugin) {
if (dirname($file) !== $slug) {
continue;
}
foreach (array('response', 'no_update') as $list) {
if (is_object($updates) && isset($updates->{$list}[$file]->id) && 0 === strpos((string) $updates->{$list}[$file]->id, self::ID_PREFIX)) {
return $file;
}
}
}
return '';
}
/**
* "View details" for plugins updated from GitHub.
*
* @param false|object|array $result Result so far.
* @param string $action plugins_api action.
* @param object $args Request arguments.
* @return false|object|array
*/
public static function plugin_information($result, $action, $args) {
if ('plugin_information' !== $action || !is_object($args) || empty($args->slug)) {
return $result;
}
$file = self::file_for_slug((string) $args->slug);
if ('' === $file) {
return $result;
}
$plugins = self::plugins();
$plugin = $plugins[$file];
$release = self::release($plugin['repo'], dirname($file), basename($file));
$data = get_plugin_data(WP_PLUGIN_DIR . '/' . $file, false, true);
$sections = array(
'description' => wpautop(esc_html(isset($data['Description']) ? wp_strip_all_tags($data['Description']) : '')),
);
if ($release) {
$sections['changelog'] = '<h4>' . esc_html($release['version']) . '</h4>'
. ('' !== trim($release['notes']) ? self::notes_html($release['notes']) : '')
. sprintf('<p><a href="%1$s" target="_blank" rel="noopener noreferrer">%2$s</a></p>', esc_url($release['url']), esc_html__('Release notes on GitHub', 'wp-plugin-starter-template'));
}
return (object) array(
'name' => $plugin['name'],
'slug' => dirname($file),
'version' => $release ? $release['version'] : $plugin['version'],
'author' => isset($data['Author']) ? $data['Author'] : '',
'homepage' => 'https://github.com/' . $plugin['repo'],
'requires' => $release ? $release['requires'] : '',
'requires_php' => $release ? $release['requires_php'] : '',
'last_updated' => $release ? $release['published'] : '',
'download_link' => $release ? self::package($plugin['repo'], $release, $plugin['asset_only']) : '',
'sections' => $sections,
'banners' => array(),
'external' => true,
);
}
/**
* Release notes (Markdown) as simple HTML: headings, lists, paragraphs,
* bold and code. Everything is escaped first.
*
* @param string $markdown Release notes.
* @return string
*/
private static function notes_html($markdown) {
$html = '';
$list = false;
foreach (preg_split('/\r\n|\r|\n/', (string) $markdown) as $line) {
$line = rtrim($line);
$text = esc_html(ltrim(preg_replace('/^(#{1,6}|[-*+])\s+/', '', trim($line))));
$text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text);
$text = preg_replace('/`([^`]+)`/', '<code>$1</code>', $text);
$item = (bool) preg_match('/^\s*[-*+]\s+/', $line);
if ($list && !$item) {
$html .= '</ul>';
$list = false;
}
if ('' === trim($line)) {
continue;
}
if ($item) {
if (!$list) {
$html .= '<ul>';
$list = true;
}
$html .= '<li>' . $text . '</li>';
} elseif (preg_match('/^#{1,6}\s/', $line)) {
$html .= '<h4>' . $text . '</h4>';
} else {
$html .= '<p>' . $text . '</p>';
}
}
return $html . ($list ? '</ul>' : '');
}
/**
* Downloads from private repositories: before WordPress downloads, swap
* the API address for the signed, short-lived address GitHub sends the
* token holder to, so WordPress downloads it itself without the token.
*
* This runs before upgrader_pre_download on purpose: some plugins' own
* updaters return false there for every package (GPLVault Updater does,
* at priority 999999999), which throws away a file already downloaded
* and leaves WordPress fetching the API address without the token
* (GitHub answers 404).
*
* @param array $options Upgrader options; 'package' is the address.
* @return array
*/
public static function private_package($options) {
if (!is_array($options) || !isset($options['package'])) {
return $options;
}
$address = self::signed_address($options['package']);
if (is_string($address)) {
$options['package'] = $address;
}
return $options;
}
/**
* Fallback for downloads that skip upgrader_package_options: ask the API
* with the token, then fetch the file from where GitHub sends us without
* it.
*
* @param mixed $reply False to let WordPress download.
* @param string $package Download address.
* @param WP_Upgrader $upgrader Upgrader.
* @return mixed File path, WP_Error or $reply.
*/
public static function private_download($reply, $package, $upgrader) {
if (false !== $reply) {
return $reply;
}
$address = self::signed_address($package);
if (null === $address) {
return $reply;
}
return is_wp_error($address) ? $address : download_url($address, 300);
}
/**
* The signed, short-lived download address of a private repository's
* release asset or source zip, asked for with the token. No token is
* needed (or wanted) there.
*
* @param mixed $package Download address.
* @return string|WP_Error|null Address; error; null when the address is
* not one of ours or there is no token.
*/
private static function signed_address($package) {
if (!is_string($package) || !preg_match('#^https://api\.github\.com/repos/([^/]+/[^/]+)/(?:releases/assets/[0-9]+|zipball/[^/?]+)$#', $package, $match)) {
return null;
}
$repo = $match[1];
$known = false;
foreach (self::plugins() as $plugin) {
$known = $known || $plugin['repo'] === $repo;
}
$token = self::token($repo);
if (!$known || '' === $token) {
return null;
}
$response = self::remote_no_redirect('GET', $package, array(
'timeout' => 30,
'headers' => array(
'Accept' => false !== strpos($package, '/releases/assets/') ? 'application/octet-stream' : 'application/vnd.github+json',
'Authorization' => 'Bearer ' . $token,
),
));
if (is_wp_error($response)) {
return $response;
}
$location = (string) wp_remote_retrieve_header($response, 'location');
if ('' === $location || !in_array((int) wp_remote_retrieve_response_code($response), array(301, 302, 303, 307, 308), true)) {
return new WP_Error('wpallstars_github_download', __('GitHub did not give a download address. Check the token’s access to the repository.', 'wp-plugin-starter-template'));
}
return $location;
}
/**
* Keep a plugin in its folder when the zip's top folder has another name
* (GitHub's source zips are named owner-repo-commit).
*
* @param string|WP_Error $source Unpacked folder.
* @param string $remote_source Folder it was unpacked in.
* @param WP_Upgrader $upgrader Upgrader.
* @param array $hook_extra Context.
* @return string|WP_Error
*/
public static function fix_folder($source, $remote_source, $upgrader, $hook_extra = array()) {
global $wp_filesystem;
if (is_wp_error($source) || empty($hook_extra['plugin']) || !$wp_filesystem) {
return $source;
}
$file = (string) $hook_extra['plugin'];
if (!isset(self::plugins()[$file])) {
return $source;
}
$folder = dirname($file);
if (basename(untrailingslashit($source)) === $folder) {
return $source;
}
$target = trailingslashit($remote_source) . $folder . '/';
$from = untrailingslashit($source);
if ($from === untrailingslashit($remote_source)) {
// A zip without a top folder: move its files aside, then into the folder.
$from = untrailingslashit($remote_source) . '-github-updater';
if (!$wp_filesystem->move(untrailingslashit($remote_source), $from, true) || !$wp_filesystem->mkdir(untrailingslashit($remote_source), FS_CHMOD_DIR)) {
return new WP_Error('wpallstars_github_folder', __('The update could not be unpacked into the plugin’s folder.', 'wp-plugin-starter-template'));
}
}
if (!$wp_filesystem->move($from, untrailingslashit($target), true)) {
return new WP_Error('wpallstars_github_folder', __('The update could not be unpacked into the plugin’s folder.', 'wp-plugin-starter-template'));
}
return $target;
}
}