PluginProbe
CryptX / 4.2.1
CryptX v4.2.1
4.2.1 4.2.0 4.1.1 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.9 2.0 2.1 2.2 2.3 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 All 93 releases
cryptx / classes / Admin / ReviewNotice.php

ReviewNotice.php in CryptX 4.2.1, at classes/Admin/ReviewNotice.php

471 lines 17.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace CryptX\Admin;
4
5 /**
6 * The one thing CryptX ever asks of the person running it.
7 *
8 * A plugin that works has nothing to say, which is the whole point of this one
9 * -- and it means the people it serves best never think about it again. Reviews
10 * are the only signal a stranger has that the plugin is still looked after, so
11 * asking once is worth it. Asking twice is not.
12 *
13 * The shape of the ask is set by guideline 11 of the plugin directory, which is
14 * worth quoting because everything below follows from it:
15 *
16 * "Upgrade prompts, notices, alerts, and the like must be limited in scope
17 * and used sparingly, be that contextually or only on the plugin's setting
18 * page. Site wide notices or embedded dashboard widgets must be dismissible
19 * or self-dismiss when resolved."
20 *
21 * So: one line, in the ordinary notice style, and four separate limits on when
22 * it may appear at all.
23 *
24 * 1. Only after a FEATURE update (4.1.x -> 4.2.x). A bugfix release triggers
25 * nothing, or a month with three patches would produce three asks.
26 * 2. Not straight away, but DELAY later. Somebody who has just clicked
27 * "update" has no opinion about the new version yet; asking then measures
28 * nothing but their patience.
29 * 3. It stops on its own after WINDOW, whether or not anybody touched it. This
30 * is the "self-dismiss when resolved" half of the guideline, and it is what
31 * keeps an ignored notice from becoming a permanent fixture.
32 * 4. Dismissing is permanent and PER USER. Permanent, because "No thanks" has
33 * to mean it -- a plugin that asks again next spring has lied. Per user,
34 * because on a site with several administrators one of them clicking the
35 * cross would otherwise decide for all the others, and none of them would
36 * ever learn why they were never asked.
37 *
38 * And one limit on WHERE, which is the other half of the same guideline: the
39 * three screens somebody actually notices a plugin update on. A notice on the
40 * media library or the comment queue is about something the person is not
41 * doing, and the sum of that -- every backend screen, for a month, after every
42 * feature release -- is what the word "sparingly" is aimed at, even when each
43 * separate showing is defensible.
44 *
45 * The two states are kept in different places on purpose, and it shows on a
46 * network: the due date is per site, in that site's option, while a refusal is
47 * user meta and user meta is network-wide. So somebody who declines on one
48 * site of a network has declined everywhere. That is the reading of "No
49 * thanks" this plugin takes -- the answer is about the plugin, not about the
50 * site it was given on.
51 *
52 * There is deliberately no incentive of any kind attached. Guideline 9 forbids
53 * "compensating, misleading, pressuring, extorting, or blackmailing others for
54 * reviews", and the cheap version of that -- unlocking something in return for
55 * a rating -- is exactly what it is aimed at.
56 *
57 * @package CryptX
58 * @since 4.2.0
59 */
60 final class ReviewNotice
61 {
62 /**
63 * Where the due date is kept, inside the plugin's own option.
64 *
65 * A separate option would have been one more row to create, migrate and
66 * remember in uninstall.php. This one rides along with everything else and
67 * is removed with it.
68 */
69 public const OPTION_KEY = 'review_prompt_due';
70
71 /**
72 * The user meta that records "do not ask me".
73 *
74 * Holds the version at which the person said no, rather than a bare 1. It
75 * costs the same and answers the question somebody will eventually have
76 * while looking at a support case.
77 */
78 public const USER_META = 'cryptx_review_dismissed';
79
80 /**
81 * The review page. Without ?rate=5: pre-selecting the rating for somebody
82 * before they have written a word is the mildest form of the nudging that
83 * guideline 9 is about, and this plugin can do without it.
84 */
85 public const REVIEW_URL = 'https://wordpress.org/support/plugin/cryptx/reviews/';
86
87 /** How long after a feature update the question becomes fair. */
88 private const DELAY = 14 * DAY_IN_SECONDS;
89
90 /** How long it then stays before giving up by itself. */
91 private const WINDOW = 30 * DAY_IN_SECONDS;
92
93 /** The query argument and the nonce action share a name on purpose. */
94 private const ACTION = 'cryptx-review-dismiss';
95
96 /**
97 * The only screens the notice may appear on.
98 *
99 * Where somebody would think about a plugin at all: the dashboard they
100 * land on, the list they update from, and this plugin's own settings.
101 */
102 private const SCREENS = ['dashboard', 'plugins', 'settings_page_cryptx'];
103
104 private const SCRIPT_HANDLE = 'cryptx-review-notice';
105
106 /**
107 * Hooks the notice in.
108 *
109 * @return void
110 */
111 public function register(): void
112 {
113 if (!is_admin()) {
114 return;
115 }
116
117 add_action('admin_init', [$this, 'handleDismissal']);
118 add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
119 add_action('admin_notices', [$this, 'render']);
120 }
121
122 /**
123 * The moment the question becomes fair, or null if this is not the kind of
124 * update that earns one.
125 *
126 * Static and free of side effects so the rule can be tested directly. The
127 * comparison is on the major.minor series rather than on version_compare()
128 * alone: 4.2.0 -> 4.2.1 is an update, and not one anybody wants to be
129 * congratulated for.
130 *
131 * @param string $from The version that was installed.
132 * @param string $to The version now installed.
133 * @param int $now The current timestamp.
134 *
135 * @return int|null The timestamp to ask at, or null for "do not ask".
136 */
137 public static function dueAfterUpdate(string $from, string $to, int $now): ?int
138 {
139 if (version_compare($to, $from, '<=')) {
140 return null;
141 }
142
143 if (self::series($from) === self::series($to)) {
144 return null;
145 }
146
147 return $now + self::DELAY;
148 }
149
150 /**
151 * The major.minor part of a version string.
152 *
153 * @param string $version A version.
154 *
155 * @return string Its series.
156 */
157 private static function series(string $version): string
158 {
159 $parts = explode('.', $version);
160
161 return ($parts[0] ?? '0') . '.' . ($parts[1] ?? '0');
162 }
163
164 /**
165 * Whether the notice may be shown to whoever is looking.
166 *
167 * @return bool True when all four limits are satisfied.
168 */
169 public function isDue(): bool
170 {
171 // The person who can act on it. An editor cannot update the plugin and
172 // has no business being asked about it.
173 if (!current_user_can('manage_options')) {
174 return false;
175 }
176
177 // In the network backend there is no per-site option to read, and the
178 // network administrator is not necessarily the person who chose this
179 // plugin. admin_notices does not fire there anyway; this says so out
180 // loud rather than relying on that staying true.
181 if (is_network_admin()) {
182 return false;
183 }
184
185 $userId = get_current_user_id();
186
187 if ($userId === 0 || get_user_meta($userId, self::USER_META, true) !== '') {
188 return false;
189 }
190
191 $options = get_option('cryptX');
192 $due = is_array($options) ? (int) ($options[self::OPTION_KEY] ?? 0) : 0;
193
194 if ($due === 0) {
195 return false;
196 }
197
198 $now = time();
199
200 return $now >= $due && $now < $due + self::WINDOW;
201 }
202
203 /**
204 * Whether this is one of the three screens the notice belongs on.
205 *
206 * Kept apart from isDue() rather than folded into it: isDue() is the rule
207 * about time and person and can be measured anywhere, while this one needs
208 * a screen to exist. Under WP-CLI there is none, and a single method would
209 * have answered "no" to everything for a reason that has nothing to do
210 * with the four limits.
211 *
212 * @return bool True on the dashboard, the plugin list or the CryptX page.
213 */
214 private function onRelevantScreen(): bool
215 {
216 if (!function_exists('get_current_screen')) {
217 return false;
218 }
219
220 $screen = get_current_screen();
221
222 if (!$screen instanceof \WP_Screen) {
223 return false;
224 }
225
226 return in_array($screen->id, self::SCREENS, true);
227 }
228
229 /**
230 * The full judgement: the right moment, the right person, the right screen.
231 *
232 * @return bool True when the notice may be printed.
233 */
234 public function shouldShow(): bool
235 {
236 return $this->onRelevantScreen() && $this->isDue();
237 }
238
239 /**
240 * Loads the small script that makes dismissing survive the page.
241 *
242 * Not built and not minified. Everything in build/ comes from
243 * @wordpress/scripts and everything in js/cryptx.min.js from a pinned
244 * terser call; adding a third path through the toolchain for twenty lines
245 * would cost more to maintain than the bytes it saves.
246 *
247 * @return void
248 */
249 public function enqueueAssets(): void
250 {
251 if (!$this->shouldShow()) {
252 return;
253 }
254
255 // The array form of the last argument rather than a bare true: it is
256 // what the directory's own checker asks for, and "defer" is right here
257 // -- nothing on the page waits for twenty lines that only matter once
258 // somebody clicks.
259 wp_enqueue_script(
260 self::SCRIPT_HANDLE,
261 CRYPTX_DIR_URL . 'js/admin-notice.js',
262 [],
263 CRYPTX_VERSION,
264 ['in_footer' => true, 'strategy' => 'defer']
265 );
266 }
267
268 /**
269 * Prints the notice.
270 *
271 * @return void
272 */
273 public function render(): void
274 {
275 if (!$this->shouldShow()) {
276 return;
277 }
278
279 $dismissUrl = $this->dismissUrl();
280
281 // "noreferrer" as well as "noopener": wp-admin sets no referrer policy
282 // of its own -- wp_strict_cross_origin_referrer() is registered on the
283 // login and activation screens and nowhere else -- so on a browser
284 // with an older default the full address of the admin screen would
285 // travel to wordpress.org. No nonce rides along with it, but there is
286 // nothing to gain by sending it either.
287 $review = sprintf(
288 '<a href="%s" target="_blank" rel="noopener noreferrer" data-cryptx-review-action="review">%s</a>',
289 esc_url(self::REVIEW_URL),
290 esc_html__('Write a review', 'cryptx')
291 );
292
293 $decline = sprintf(
294 '<a href="%s" data-cryptx-review-action="dismiss">%s</a>',
295 esc_url($dismissUrl),
296 esc_html__('No thanks', 'cryptx')
297 );
298
299 // The sprintf() is guarded, and this is the first translated format
300 // string in the plugin that carries arguments at all -- so the failure
301 // mode is worth naming. A translation is external input that arrives
302 // from translate.wordpress.org without anybody here seeing it. One
303 // carrying a "%4$s" makes sprintf() throw an ArgumentCountError under
304 // PHP 8, and it would throw on EVERY admin screen for as long as the
305 // window is open: a white screen, recovery mode, and the plugin
306 // switched off, all for a sentence nobody needed. A notice that fails
307 // to appear costs nothing by comparison.
308 try {
309 $message = sprintf(
310 /* translators: 1: Plugin name and version, in bold. 2: "Write a review" link. 3: "No thanks" link. */
311 __(
312 '%1$s &ndash; 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 &middot; %3$s',
313 'cryptx'
314 ),
315 '<strong>' . esc_html(sprintf('CryptX %s', CRYPTX_VERSION)) . '</strong>',
316 $review,
317 $decline
318 );
319 } catch (\Throwable) {
320 // Caught without binding: there is nothing to do with it here, and
321 // an unused variable reads as a forgotten log line.
322 return;
323 }
324
325 // wp_admin_notice() runs the finished markup through wp_kses_post(),
326 // which keeps <strong>, <a href target rel> and data-* attributes and
327 // drops everything else. Measured, not assumed -- the data-* part is
328 // the one that would have failed quietly.
329 wp_admin_notice($message, [
330 'type' => 'info',
331 'dismissible' => true,
332 'id' => 'cryptx-review-notice',
333 'attributes' => [
334 'data-cryptx-review' => $dismissUrl,
335 ],
336 ]);
337 }
338
339 /**
340 * Records the refusal when the link was followed without JavaScript.
341 *
342 * The cross that WordPress draws on a dismissible notice hides it and
343 * nothing more -- it is undone by the next page load. The script turns
344 * both the cross and the two links into a request to this handler; this
345 * method is what happens when there is no script, and it is why "No
346 * thanks" is a real link with a real target rather than a href="#".
347 *
348 * @return void
349 */
350 public function handleDismissal(): void
351 {
352 if (!isset($_GET[self::ACTION])) {
353 return;
354 }
355
356 // A fetched link is not an answer. A browser that speculatively loads
357 // what it thinks will be clicked next sends the session's cookies with
358 // it, so the nonce and the capability check below would both be
359 // satisfied -- and the person would have declined without knowing that
360 // a question had been asked. They would simply never see one.
361 //
362 // Checked before the nonce rather than after: a prefetch that fails
363 // check_admin_referer() gets wp_die()'d, and a browser holding that
364 // page ready shows it if the link is then really clicked.
365 if (self::isSpeculativeRequest()) {
366 return;
367 }
368
369 if (!current_user_can('manage_options')) {
370 return;
371 }
372
373 // Dies on a bad or missing nonce.
374 check_admin_referer(self::ACTION);
375
376 update_user_meta(get_current_user_id(), self::USER_META, CRYPTX_VERSION);
377
378 // Back where they were. wp_get_referer() is already checked against
379 // this site's host, and wp_safe_redirect() refuses a foreign one a
380 // second time -- so the value never has to be trusted.
381 wp_safe_redirect(wp_get_referer() ?: admin_url());
382 exit;
383 }
384
385 /**
386 * Whether the browser is loading this ahead of time rather than being told to.
387 *
388 * Four headers for one idea, because no two engines agreed on a name
389 * before "Sec-Purpose" was standardised: Chromium sends "Sec-Purpose:
390 * prefetch" (and "prerender"), older Chromium "Purpose: prefetch", Safari
391 * "X-Purpose: preview", Firefox "X-Moz: prefetch".
392 *
393 * This is not a security control -- it is not trustworthy enough to be one
394 * and does not need to be. Getting it wrong in either direction costs at
395 * most one review prompt, and the guard fails towards asking again.
396 *
397 * @return bool True when nobody clicked anything.
398 */
399 private static function isSpeculativeRequest(): bool
400 {
401 $headers = ['HTTP_SEC_PURPOSE', 'HTTP_PURPOSE', 'HTTP_X_PURPOSE', 'HTTP_X_MOZ'];
402
403 foreach ($headers as $header) {
404 if (!isset($_SERVER[$header])) {
405 continue;
406 }
407
408 // Unslashed and sanitised even though the value is only ever
409 // compared against three words and never stored or printed. It is
410 // what the rest of the plugin does with $_SERVER, and a header
411 // read that looks different from the others invites the question
412 // of which one is wrong.
413 $value = strtolower(sanitize_text_field(wp_unslash($_SERVER[$header])));
414
415 if ($value === '') {
416 continue;
417 }
418
419 foreach (['prefetch', 'prerender', 'preview'] as $word) {
420 if (strpos($value, $word) !== false) {
421 return true;
422 }
423 }
424 }
425
426 return false;
427 }
428
429 /**
430 * The URL that records a refusal.
431 *
432 * Points at the dashboard rather than at the current screen: building it
433 * from REQUEST_URI would reflect whatever the browser sent back into an
434 * href, and there is nothing to gain by it -- the handler redirects to the
435 * referring page anyway.
436 *
437 * @return string A nonce-carrying admin URL.
438 */
439 private function dismissUrl(): string
440 {
441 return wp_nonce_url(
442 add_query_arg(self::ACTION, '1', admin_url()),
443 self::ACTION
444 );
445 }
446
447 /**
448 * Sets the due date after an update, if the update earns one.
449 *
450 * Called from the migration, which is the only place that knows both
451 * versions. Returns the option array rather than writing it: the migration
452 * writes once, at the end, and a second write here would be a second
453 * chance to get it wrong.
454 *
455 * @param array<string, mixed> $options The option array being migrated.
456 * @param string $from The version that was installed.
457 *
458 * @return array<string, mixed> The option array, possibly with a due date.
459 */
460 public static function scheduleAfterUpdate(array $options, string $from): array
461 {
462 $due = self::dueAfterUpdate($from, CRYPTX_VERSION, time());
463
464 if ($due !== null) {
465 $options[self::OPTION_KEY] = $due;
466 }
467
468 return $options;
469 }
470 }
471