host(); if ( ! $host ) { return; } add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 3 ); // 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' ) ); // "View version details" next to the update notice. add_filter( 'plugins_api', array( $this, 'plugin_details' ), 10, 3 ); } /** * 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. * @param string $plugin_file Which plugin WordPress is asking about. * @return array|false */ public function offer_update( $update, $plugin_data, $plugin_file = '' ) { // The filter name is built from the HOSTNAME, not the repository: // every plugin that updates from gitea.nolimit.dk shares the one hook // `update_plugins_gitea.nolimit.dk`, and WordPress runs every callback // on it for every one of those plugins. Without this check a plugin // answers for its neighbour and offers it its own release — measured // on a live shop, where a subscriptions plugin was offered this one's // 1.4.4 and told it was an update for itself. // // The `10, 3` on the add_filter above is half of this fix. With the // accepted-argument count left at 2 the third argument never arrives, // $plugin_file stays empty, and the guard silently does nothing — the // same shape of bug it exists to fix. if ( '' !== $plugin_file && plugin_basename( WC_EDM_PLUGIN_FILE ) !== $plugin_file ) { return $update; } // Shared by both branches on purpose. When these were two separate // literals the no-release branch was simply missing them, and the // symptom is the grey default plug on Dashboard -> Updates for as long // as the feed stays down. // // Served from the shop's own copy of the plugin rather than from the // release host: 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', ); $release = $this->latest_release(); // 🔴 Answer with the INSTALLED version rather than returning $update // untouched. A plugin that answers neither way is in neither the // `response` nor the `no_update` list, and WP_Plugins_List_Table builds // the per-plugin "Enable auto-updates" link from that transient — so // the row reads "Auto-updates are not available for this plugin", // which reads as a setting rather than as a failed HTTP request. The // shop then quietly stops being offered fixes, and nothing says so. // // The version is true and comparable, so WordPress files it under // no_update and the row behaves normally until the feed answers again. if ( ! $release ) { return array( 'slug' => dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) ), 'version' => isset( $plugin_data['Version'] ) ? $plugin_data['Version'] : WC_EDM_VERSION, 'package' => '', 'url' => self::update_uri(), 'requires' => isset( $plugin_data['RequiresWP'] ) ? $plugin_data['RequiresWP'] : '', 'requires_php' => isset( $plugin_data['RequiresPHP'] ) ? $plugin_data['RequiresPHP'] : '', 'icons' => $icons, ); } 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. 'icons' => $icons, ); } /** * Answer the "View version details" modal. * * WordPress builds that link as * plugin-install.php?tab=plugin-information&plugin=, which calls * plugins_api() — and plugins_api() asks **wordpress.org**. This plugin is * not there, so without this filter the link opens a modal containing an * error, on the same screen that is otherwise telling the shop owner an * update is ready. Nothing warns you: the link looks like every other * plugin's. * * @param false|object|array $result Response so far. * @param string $action Requested action. * @param object $args Request arguments. * @return false|object|array */ public function plugin_details( $result, $action, $args ) { $slug = dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) ); if ( 'plugin_information' !== $action || empty( $args->slug ) || $args->slug !== $slug ) { return $result; } require_once ABSPATH . 'wp-admin/includes/plugin.php'; $plugin = get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ); $release = $this->latest_release(); $notes = $release && '' !== $release['notes'] ? $release['notes'] : ''; return (object) array( 'name' => $plugin['Name'], 'slug' => $slug, 'version' => $release ? $release['version'] : $plugin['Version'], 'author' => $plugin['Author'], 'author_profile'=> $plugin['AuthorURI'], 'homepage' => $plugin['PluginURI'], 'requires' => $plugin['RequiresWP'], 'requires_php' => $plugin['RequiresPHP'], 'tested' => $release ? $release['tested'] : '', 'download_link' => $release ? $release['package'] : '', 'icons' => array( 'svg' => WC_EDM_PLUGIN_URL . 'assets/icon.svg' ), 'sections' => array( 'description' => wpautop( esc_html( $plugin['Description'] ) ), // Plain text from the release notes, not markdown: rendering it // as HTML would mean trusting whatever is in a release body. 'changelog' => '' !== $notes ? wpautop( esc_html( $notes ) ) : wpautop( esc_html__( 'Release notes are published with each release.', 'wc-email-discount' ) ), ), ); } /** * 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, // The release notes are the changelog. Kept so the "View version // details" modal has something true to show. 'notes' => isset( $body['body'] ) ? (string) $body['body'] : '', ); } /** * 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] ) ); } }