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; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $body ) || empty( $body['tag_name'] ) ) { set_transient( self::TRANSIENT, 'none', 15 * MINUTE_IN_SECONDS ); return null; } $package = ''; $tested = ''; foreach ( isset( $body['assets'] ) ? $body['assets'] : array() as $asset ) { if ( empty( $asset['name'] ) || empty( $asset['browser_download_url'] ) ) { continue; } if ( substr( $asset['name'], -4 ) === '.zip' ) { $package = $asset['browser_download_url']; } } // 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. if ( ! empty( $body['body'] ) && preg_match( '/^Tested up to:\s*(\S+)/mi', $body['body'], $m ) ) { $tested = $m[1]; } if ( '' === $package ) { // A release with no zip attached is a half-finished release. // Offering it would hand WordPress a download it cannot install. set_transient( self::TRANSIENT, 'none', 15 * MINUTE_IN_SECONDS ); return null; } $release = array( 'version' => ltrim( $body['tag_name'], 'vV' ), 'package' => $package, 'url' => ! empty( $body['html_url'] ) ? $body['html_url'] : $uri, 'tested' => $tested, ); set_transient( self::TRANSIENT, $release, self::TTL ); return $release; } /** * 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] ) ); } }