Files
nolimit-discounts/includes/class-updater.php
T
kristianwindandClaude Opus 5 200f791f40 An icon, a Check again that checks, and the auto-update rule written down
Three gaps found by being asked plain questions about it.

**The auto-update toggle was a promise nobody had checked.** offer_update()
answers even when the installed version is current, on the reasoning that the
answer has to land in WordPress's no_update list for the "Enable auto-updates"
link to exist at all. That was a comment and an intention; it is now an
assertion against the real WP_Plugins_List_Table, which is where the flag is
actually read:

    $plugin_data = array_merge( (array) $plugin_info->no_update[ $plugin_file ],
                               array( 'update-supported' => true ), $plugin_data );

A plugin in neither response nor no_update gets "Auto-updates are not available
for this plugin" while looking entirely healthy.

**An icon.** Without one, Dashboard → Updates shows a generic grey plug.
assets/icon.svg, handed over in the offer's icons key, served from the shop's
own copy rather than the release host — one fewer thing that must be reachable
for wp-admin to render, and it cannot go mixed content on an HTTPS shop. Drawn
as paths rather than <text>: an SVG that leans on a font renders differently on
every machine, and this one is shown at about 40px, which is the size where
that is the difference between a mark and a smudge.

**"Check again" now checks again.** The six-hour cache is right for the check
WordPress runs twice a day and wrong for a link whose whole promise is that it
looks now. Publishing a release and then clicking it showed nothing for up to
six hours, which reads exactly like a broken updater.

And docs/REUSE.md was missing all of this: it covered Update URI, where to
instantiate the updater and making the repo public, but not the one rule that
is genuinely easy to get wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:23:05 +02:00

314 lines
9.9 KiB
PHP

<?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 );
// And "Check again" has to actually check again.
add_action( 'admin_init', array( $this, 'forget_on_force_check' ) );
}
/**
* 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'] : '',
// Without this, Dashboard → Updates shows the generic grey plug
// next to the plugin. WordPress takes the first of svg, 2x, 1x,
// default that is set.
//
// Served from the shop's own copy of the plugin rather than from
// the release host: it is one fewer thing that has to be up and
// reachable for wp-admin to render correctly, it cannot go mixed
// content on an HTTPS shop, and the icon is in the zip anyway.
'icons' => array(
'svg' => WC_EDM_PLUGIN_URL . 'assets/icon.svg',
'default' => WC_EDM_PLUGIN_URL . 'assets/icon.svg',
),
);
}
/**
* Drop the cached feed.
*/
public function forget() {
delete_transient( self::TRANSIENT );
}
/**
* Honour the "Check again" link on Dashboard → Updates.
*
* The six-hour cache is right for the check WordPress runs twice a day on
* its own, and wrong for a button whose entire promise is that it looks
* right now. Without this, someone who has just published a release clicks
* it, sees nothing, and concludes the update mechanism is broken.
*
* No nonce to verify: this reads a link WordPress itself renders, and the
* only effect is discarding a cache — the worst a forged request achieves
* is one extra HTTP GET.
*/
public function forget_on_force_check() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $_GET['force-check'] ) || ! current_user_can( 'update_plugins' ) ) {
return;
}
$this->forget();
}
/**
* 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] )
);
}
}