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] ) ); } }