Files
nolimit-discounts/includes/class-updater.php
T

278 lines
8.4 KiB
PHP
Raw Normal View History

<?php
/**
* Updates from the plugin's own release feed.
*
* WordPress only offers updates for plugins it knows a source for. This is
* that source: the Gitea repository named in the plugin header's "Update URI".
*
* It hooks `update_plugins_{$hostname}` rather than rewriting the update
* transient by hand. That filter is the supported path since WordPress 5.8 and
* it brings one guarantee worth more than the tidiness: a plugin with an
* "Update URI" pointing away from wordpress.org is never asked about there, so
* a .org plugin that happens to share this slug can never be pushed onto a
* shop running this one.
*
* Nothing about the site is sent. The request is a plain unauthenticated GET
* for a file every visitor to that repository can read, and the user agent is
* overridden because WordPress's default one carries the site's own URL in it.
*
* @package WC_Email_Discount_Manager
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Offers releases from the repository named in the plugin header.
*/
class WC_EDM_Updater {
/**
* Where the answer is cached between checks.
*/
const TRANSIENT = 'wc_edm_release_feed';
/**
* How long that cache lives. WordPress checks for updates twice a day; a
* shorter window would only add requests, a longer one would delay a fix.
*/
const TTL = 6 * HOUR_IN_SECONDS;
/**
* Hook into the update check for our own host only.
*/
public function __construct() {
$host = $this->host();
if ( ! $host ) {
return;
}
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 2 );
// Without this the old answer is served for up to six hours after an
// update, and the plugins screen keeps offering a version already
// installed.
add_action( 'upgrader_process_complete', array( $this, 'forget' ), 10, 0 );
}
/**
* Memoised update URI, so the plugin file is read at most once a request.
*
* @var string|null
*/
private static $uri = null;
/**
* The repository this plugin updates from, as stated in its own header.
*
* Read from the header rather than a constant so that there is exactly one
* place saying where updates come from — but memoised, because the filter
* is registered on every request a shop serves, not just in wp-admin, and
* get_file_data() opens and scans the file each time it is asked.
*
* @return string
*/
public static function update_uri() {
if ( null === self::$uri ) {
$data = get_file_data( WC_EDM_PLUGIN_FILE, array( 'uri' => 'Update URI' ) );
self::$uri = isset( $data['uri'] ) ? trim( $data['uri'] ) : '';
}
return self::$uri;
}
/**
* Hostname of the update URI, or an empty string when there is none.
*
* @return string
*/
private function host() {
$host = wp_parse_url( self::update_uri(), PHP_URL_HOST );
return is_string( $host ) ? $host : '';
}
/**
* Answer WordPress's update check for this plugin.
*
* Always returns what the feed says, even when it is the version already
* installed: WordPress compares the versions itself, and a plugin that
* answers "no update" by returning false never appears in the no_update
* list — which is what the "Enable auto-updates" link on the plugins
* screen is built from. Staying silent here is how a plugin ends up
* unable to auto-update while looking perfectly configured.
*
* @param array|false $update Update offered so far.
* @param array $plugin_data Headers of the installed plugin.
* @return array|false
*/
public function offer_update( $update, $plugin_data ) {
$release = $this->latest_release();
if ( ! $release ) {
return $update;
}
return array(
'slug' => dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) ),
'version' => $release['version'],
'package' => $release['package'],
'url' => $release['url'],
'tested' => $release['tested'],
'requires' => isset( $plugin_data['RequiresWP'] ) ? $plugin_data['RequiresWP'] : '',
'requires_php' => isset( $plugin_data['RequiresPHP'] ) ? $plugin_data['RequiresPHP'] : '',
);
}
/**
* Drop the cached feed.
*/
public function forget() {
delete_transient( self::TRANSIENT );
}
/**
* Read the newest release from the repository.
*
* @return array|null { version, package, url, tested }, or null.
*/
private function latest_release() {
$cached = get_transient( self::TRANSIENT );
if ( is_array( $cached ) ) {
return $cached;
}
if ( 'none' === $cached ) {
// A failure is cached too, briefly. Otherwise an unreachable host
// turns every admin page load into a blocking HTTP request.
return null;
}
$uri = self::update_uri();
$api = $this->api_url( $uri );
if ( ! $api ) {
return null;
}
$response = wp_remote_get(
$api,
array(
'timeout' => 10,
'headers' => array( 'Accept' => 'application/json' ),
// WordPress's default user agent is
// "WordPress/6.8; https://the-shop.example" — it would tell
// the update host which shops are running this plugin. This
// plugin does not report that anywhere, so it must not leak
// it here either.
'user-agent' => 'wc-email-discount-manager updater',
)
);
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
set_transient( self::TRANSIENT, 'none', 15 * MINUTE_IN_SECONDS );
return null;
}
$release = $this->parse_release( json_decode( wp_remote_retrieve_body( $response ), true ) );
if ( ! $release ) {
set_transient( self::TRANSIENT, 'none', 15 * MINUTE_IN_SECONDS );
return null;
}
set_transient( self::TRANSIENT, $release, self::TTL );
return $release;
}
/**
* Turn a release API response into what WordPress needs.
*
* The download address is rebuilt from the repository address in the
* plugin header rather than taken from the response's
* browser_download_url. That field is built from the forge's own
* configured root URL, which is not necessarily the address the outside
* world reaches it on — gitea.nolimit.dk returns
* "http://57.129.89.117:25001/..." there, a plain-HTTP raw IP that
* bypasses the Cloudflare tunnel in front of it. A shop handed that would
* be downloading plugin code over an unencrypted connection to an address
* that is free to change. Measured on the v1.4.0 release.
*
* @param mixed $body Decoded API response.
* @return array|null { version, package, url, tested }, or null.
*/
private function parse_release( $body ) {
if ( ! is_array( $body ) || empty( $body['tag_name'] ) ) {
return null;
}
$tag = $body['tag_name'];
$base = rtrim( self::update_uri(), '/' );
$zip = '';
foreach ( isset( $body['assets'] ) ? $body['assets'] : array() as $asset ) {
if ( ! empty( $asset['name'] ) && '.zip' === substr( $asset['name'], -4 ) ) {
$zip = $asset['name'];
}
}
if ( '' === $zip ) {
// A release with no zip attached is a half-finished release.
// Offering it would hand WordPress a download it cannot install.
return null;
}
// The release notes carry the compatibility line the release workflow
// writes, so wp-admin can show what this version was actually tested
// against instead of what the previous one claimed.
$tested = '';
if ( ! empty( $body['body'] ) && preg_match( '/^Tested up to:\s*(\S+)/mi', $body['body'], $m ) ) {
$tested = $m[1];
}
return array(
'version' => ltrim( $tag, 'vV' ),
'package' => $base . '/releases/download/' . rawurlencode( $tag ) . '/' . rawurlencode( $zip ),
'url' => $base . '/releases/tag/' . rawurlencode( $tag ),
'tested' => $tested,
);
}
/**
* Turn the repository's web address into its "latest release" API address.
*
* https://gitea.example/owner/repo
* -> https://gitea.example/api/v1/repos/owner/repo/releases/latest
*
* Derived rather than configured, so the plugin header stays the single
* place that says where updates come from.
*
* @param string $uri Repository web address.
* @return string Empty when the address is not a repository address.
*/
private function api_url( $uri ) {
$parts = wp_parse_url( $uri );
if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) || empty( $parts['path'] ) ) {
return '';
}
$segments = array_values( array_filter( explode( '/', $parts['path'] ) ) );
if ( count( $segments ) < 2 ) {
return '';
}
return sprintf(
'%s://%s/api/v1/repos/%s/%s/releases/latest',
$parts['scheme'],
$parts['host'],
rawurlencode( $segments[0] ),
rawurlencode( $segments[1] )
);
}
}