4.2.x). A bugfix release triggers * nothing, or a month with three patches would produce three asks. * 2. Not straight away, but DELAY later. Somebody who has just clicked * "update" has no opinion about the new version yet; asking then measures * nothing but their patience. * 3. It stops on its own after WINDOW, whether or not anybody touched it. This * is the "self-dismiss when resolved" half of the guideline, and it is what * keeps an ignored notice from becoming a permanent fixture. * 4. Dismissing is permanent and PER USER. Permanent, because "No thanks" has * to mean it -- a plugin that asks again next spring has lied. Per user, * because on a site with several administrators one of them clicking the * cross would otherwise decide for all the others, and none of them would * ever learn why they were never asked. * * And one limit on WHERE, which is the other half of the same guideline: the * three screens somebody actually notices a plugin update on. A notice on the * media library or the comment queue is about something the person is not * doing, and the sum of that -- every backend screen, for a month, after every * feature release -- is what the word "sparingly" is aimed at, even when each * separate showing is defensible. * * The two states are kept in different places on purpose, and it shows on a * network: the due date is per site, in that site's option, while a refusal is * user meta and user meta is network-wide. So somebody who declines on one * site of a network has declined everywhere. That is the reading of "No * thanks" this plugin takes -- the answer is about the plugin, not about the * site it was given on. * * There is deliberately no incentive of any kind attached. Guideline 9 forbids * "compensating, misleading, pressuring, extorting, or blackmailing others for * reviews", and the cheap version of that -- unlocking something in return for * a rating -- is exactly what it is aimed at. * * @package CryptX * @since 4.2.0 */ final class ReviewNotice { /** * Where the due date is kept, inside the plugin's own option. * * A separate option would have been one more row to create, migrate and * remember in uninstall.php. This one rides along with everything else and * is removed with it. */ public const OPTION_KEY = 'review_prompt_due'; /** * The user meta that records "do not ask me". * * Holds the version at which the person said no, rather than a bare 1. It * costs the same and answers the question somebody will eventually have * while looking at a support case. */ public const USER_META = 'cryptx_review_dismissed'; /** * The review page. Without ?rate=5: pre-selecting the rating for somebody * before they have written a word is the mildest form of the nudging that * guideline 9 is about, and this plugin can do without it. */ public const REVIEW_URL = 'https://wordpress.org/support/plugin/cryptx/reviews/'; /** How long after a feature update the question becomes fair. */ private const DELAY = 14 * DAY_IN_SECONDS; /** How long it then stays before giving up by itself. */ private const WINDOW = 30 * DAY_IN_SECONDS; /** The query argument and the nonce action share a name on purpose. */ private const ACTION = 'cryptx-review-dismiss'; /** * The only screens the notice may appear on. * * Where somebody would think about a plugin at all: the dashboard they * land on, the list they update from, and this plugin's own settings. */ private const SCREENS = ['dashboard', 'plugins', 'settings_page_cryptx']; private const SCRIPT_HANDLE = 'cryptx-review-notice'; /** * Hooks the notice in. * * @return void */ public function register(): void { if (!is_admin()) { return; } add_action('admin_init', [$this, 'handleDismissal']); add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']); add_action('admin_notices', [$this, 'render']); } /** * The moment the question becomes fair, or null if this is not the kind of * update that earns one. * * Static and free of side effects so the rule can be tested directly. The * comparison is on the major.minor series rather than on version_compare() * alone: 4.2.0 -> 4.2.1 is an update, and not one anybody wants to be * congratulated for. * * @param string $from The version that was installed. * @param string $to The version now installed. * @param int $now The current timestamp. * * @return int|null The timestamp to ask at, or null for "do not ask". */ public static function dueAfterUpdate(string $from, string $to, int $now): ?int { if (version_compare($to, $from, '<=')) { return null; } if (self::series($from) === self::series($to)) { return null; } return $now + self::DELAY; } /** * The major.minor part of a version string. * * @param string $version A version. * * @return string Its series. */ private static function series(string $version): string { $parts = explode('.', $version); return ($parts[0] ?? '0') . '.' . ($parts[1] ?? '0'); } /** * Whether the notice may be shown to whoever is looking. * * @return bool True when all four limits are satisfied. */ public function isDue(): bool { // The person who can act on it. An editor cannot update the plugin and // has no business being asked about it. if (!current_user_can('manage_options')) { return false; } // In the network backend there is no per-site option to read, and the // network administrator is not necessarily the person who chose this // plugin. admin_notices does not fire there anyway; this says so out // loud rather than relying on that staying true. if (is_network_admin()) { return false; } $userId = get_current_user_id(); if ($userId === 0 || get_user_meta($userId, self::USER_META, true) !== '') { return false; } $options = get_option('cryptX'); $due = is_array($options) ? (int) ($options[self::OPTION_KEY] ?? 0) : 0; if ($due === 0) { return false; } $now = time(); return $now >= $due && $now < $due + self::WINDOW; } /** * Whether this is one of the three screens the notice belongs on. * * Kept apart from isDue() rather than folded into it: isDue() is the rule * about time and person and can be measured anywhere, while this one needs * a screen to exist. Under WP-CLI there is none, and a single method would * have answered "no" to everything for a reason that has nothing to do * with the four limits. * * @return bool True on the dashboard, the plugin list or the CryptX page. */ private function onRelevantScreen(): bool { if (!function_exists('get_current_screen')) { return false; } $screen = get_current_screen(); if (!$screen instanceof \WP_Screen) { return false; } return in_array($screen->id, self::SCREENS, true); } /** * The full judgement: the right moment, the right person, the right screen. * * @return bool True when the notice may be printed. */ public function shouldShow(): bool { return $this->onRelevantScreen() && $this->isDue(); } /** * Loads the small script that makes dismissing survive the page. * * Not built and not minified. Everything in build/ comes from * @wordpress/scripts and everything in js/cryptx.min.js from a pinned * terser call; adding a third path through the toolchain for twenty lines * would cost more to maintain than the bytes it saves. * * @return void */ public function enqueueAssets(): void { if (!$this->shouldShow()) { return; } // The array form of the last argument rather than a bare true: it is // what the directory's own checker asks for, and "defer" is right here // -- nothing on the page waits for twenty lines that only matter once // somebody clicks. wp_enqueue_script( self::SCRIPT_HANDLE, CRYPTX_DIR_URL . 'js/admin-notice.js', [], CRYPTX_VERSION, ['in_footer' => true, 'strategy' => 'defer'] ); } /** * Prints the notice. * * @return void */ public function render(): void { if (!$this->shouldShow()) { return; } $dismissUrl = $this->dismissUrl(); // "noreferrer" as well as "noopener": wp-admin sets no referrer policy // of its own -- wp_strict_cross_origin_referrer() is registered on the // login and activation screens and nowhere else -- so on a browser // with an older default the full address of the admin screen would // travel to wordpress.org. No nonce rides along with it, but there is // nothing to gain by sending it either. $review = sprintf( '%s', esc_url(self::REVIEW_URL), esc_html__('Write a review', 'cryptx') ); $decline = sprintf( '%s', esc_url($dismissUrl), esc_html__('No thanks', 'cryptx') ); // The sprintf() is guarded, and this is the first translated format // string in the plugin that carries arguments at all -- so the failure // mode is worth naming. A translation is external input that arrives // from translate.wordpress.org without anybody here seeing it. One // carrying a "%4$s" makes sprintf() throw an ArgumentCountError under // PHP 8, and it would throw on EVERY admin screen for as long as the // window is open: a white screen, recovery mode, and the plugin // switched off, all for a sentence nobody needed. A notice that fails // to appear costs nothing by comparison. try { $message = sprintf( /* translators: 1: Plugin name and version, in bold. 2: "Write a review" link. 3: "No thanks" link. */ __( '%1$s – thanks for keeping it up to date. If it is doing its job quietly on your site, a short review helps other people find it. %2$s · %3$s', 'cryptx' ), '' . esc_html(sprintf('CryptX %s', CRYPTX_VERSION)) . '', $review, $decline ); } catch (\Throwable) { // Caught without binding: there is nothing to do with it here, and // an unused variable reads as a forgotten log line. return; } // wp_admin_notice() runs the finished markup through wp_kses_post(), // which keeps , and data-* attributes and // drops everything else. Measured, not assumed -- the data-* part is // the one that would have failed quietly. wp_admin_notice($message, [ 'type' => 'info', 'dismissible' => true, 'id' => 'cryptx-review-notice', 'attributes' => [ 'data-cryptx-review' => $dismissUrl, ], ]); } /** * Records the refusal when the link was followed without JavaScript. * * The cross that WordPress draws on a dismissible notice hides it and * nothing more -- it is undone by the next page load. The script turns * both the cross and the two links into a request to this handler; this * method is what happens when there is no script, and it is why "No * thanks" is a real link with a real target rather than a href="#". * * @return void */ public function handleDismissal(): void { if (!isset($_GET[self::ACTION])) { return; } // A fetched link is not an answer. A browser that speculatively loads // what it thinks will be clicked next sends the session's cookies with // it, so the nonce and the capability check below would both be // satisfied -- and the person would have declined without knowing that // a question had been asked. They would simply never see one. // // Checked before the nonce rather than after: a prefetch that fails // check_admin_referer() gets wp_die()'d, and a browser holding that // page ready shows it if the link is then really clicked. if (self::isSpeculativeRequest()) { return; } if (!current_user_can('manage_options')) { return; } // Dies on a bad or missing nonce. check_admin_referer(self::ACTION); update_user_meta(get_current_user_id(), self::USER_META, CRYPTX_VERSION); // Back where they were. wp_get_referer() is already checked against // this site's host, and wp_safe_redirect() refuses a foreign one a // second time -- so the value never has to be trusted. wp_safe_redirect(wp_get_referer() ?: admin_url()); exit; } /** * Whether the browser is loading this ahead of time rather than being told to. * * Four headers for one idea, because no two engines agreed on a name * before "Sec-Purpose" was standardised: Chromium sends "Sec-Purpose: * prefetch" (and "prerender"), older Chromium "Purpose: prefetch", Safari * "X-Purpose: preview", Firefox "X-Moz: prefetch". * * This is not a security control -- it is not trustworthy enough to be one * and does not need to be. Getting it wrong in either direction costs at * most one review prompt, and the guard fails towards asking again. * * @return bool True when nobody clicked anything. */ private static function isSpeculativeRequest(): bool { $headers = ['HTTP_SEC_PURPOSE', 'HTTP_PURPOSE', 'HTTP_X_PURPOSE', 'HTTP_X_MOZ']; foreach ($headers as $header) { if (!isset($_SERVER[$header])) { continue; } // Unslashed and sanitised even though the value is only ever // compared against three words and never stored or printed. It is // what the rest of the plugin does with $_SERVER, and a header // read that looks different from the others invites the question // of which one is wrong. $value = strtolower(sanitize_text_field(wp_unslash($_SERVER[$header]))); if ($value === '') { continue; } foreach (['prefetch', 'prerender', 'preview'] as $word) { if (strpos($value, $word) !== false) { return true; } } } return false; } /** * The URL that records a refusal. * * Points at the dashboard rather than at the current screen: building it * from REQUEST_URI would reflect whatever the browser sent back into an * href, and there is nothing to gain by it -- the handler redirects to the * referring page anyway. * * @return string A nonce-carrying admin URL. */ private function dismissUrl(): string { return wp_nonce_url( add_query_arg(self::ACTION, '1', admin_url()), self::ACTION ); } /** * Sets the due date after an update, if the update earns one. * * Called from the migration, which is the only place that knows both * versions. Returns the option array rather than writing it: the migration * writes once, at the end, and a second write here would be a second * chance to get it wrong. * * @param array $options The option array being migrated. * @param string $from The version that was installed. * * @return array The option array, possibly with a due date. */ public static function scheduleAfterUpdate(array $options, string $from): array { $due = self::dueAfterUpdate($from, CRYPTX_VERSION, time()); if ($due !== null) { $options[self::OPTION_KEY] = $due; } return $options; } }