2026-09-16 10:34:38 +02:00
|
|
|
<?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 );
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 10:36:33 +02:00
|
|
|
/**
|
|
|
|
|
* Memoised update URI, so the plugin file is read at most once a request.
|
|
|
|
|
*
|
|
|
|
|
* @var string|null
|
|
|
|
|
*/
|
|
|
|
|
private static $uri = null;
|
|
|
|
|
|
2026-09-16 10:34:38 +02:00
|
|
|
/**
|
|
|
|
|
* The repository this plugin updates from, as stated in its own header.
|
|
|
|
|
*
|
2026-09-16 10:36:33 +02:00
|
|
|
* 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.
|
|
|
|
|
*
|
2026-09-16 10:34:38 +02:00
|
|
|
* @return string
|
|
|
|
|
*/
|
|
|
|
|
public static function update_uri() {
|
2026-09-16 10:36:33 +02:00
|
|
|
if ( null === self::$uri ) {
|
|
|
|
|
$data = get_file_data( WC_EDM_PLUGIN_FILE, array( 'uri' => 'Update URI' ) );
|
|
|
|
|
self::$uri = isset( $data['uri'] ) ? trim( $data['uri'] ) : '';
|
|
|
|
|
}
|
2026-09-16 10:34:38 +02:00
|
|
|
|
2026-09-16 10:36:33 +02:00
|
|
|
return self::$uri;
|
2026-09-16 10:34:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 10:49:52 +02:00
|
|
|
$release = $this->parse_release( json_decode( wp_remote_retrieve_body( $response ), true ) );
|
2026-09-16 10:34:38 +02:00
|
|
|
|
2026-09-16 10:49:52 +02:00
|
|
|
if ( ! $release ) {
|
2026-09-16 10:34:38 +02:00
|
|
|
set_transient( self::TRANSIENT, 'none', 15 * MINUTE_IN_SECONDS );
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 10:49:52 +02:00
|
|
|
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 = '';
|
2026-09-16 10:34:38 +02:00
|
|
|
|
|
|
|
|
foreach ( isset( $body['assets'] ) ? $body['assets'] : array() as $asset ) {
|
2026-09-16 10:49:52 +02:00
|
|
|
if ( ! empty( $asset['name'] ) && '.zip' === substr( $asset['name'], -4 ) ) {
|
|
|
|
|
$zip = $asset['name'];
|
2026-09-16 10:34:38 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 10:49:52 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 10:34:38 +02:00
|
|
|
// 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.
|
2026-09-16 10:49:52 +02:00
|
|
|
$tested = '';
|
2026-09-16 10:34:38 +02:00
|
|
|
if ( ! empty( $body['body'] ) && preg_match( '/^Tested up to:\s*(\S+)/mi', $body['body'], $m ) ) {
|
|
|
|
|
$tested = $m[1];
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 10:49:52 +02:00
|
|
|
return array(
|
|
|
|
|
'version' => ltrim( $tag, 'vV' ),
|
|
|
|
|
'package' => $base . '/releases/download/' . rawurlencode( $tag ) . '/' . rawurlencode( $zip ),
|
|
|
|
|
'url' => $base . '/releases/tag/' . rawurlencode( $tag ),
|
2026-09-16 10:34:38 +02:00
|
|
|
'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] )
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|