PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
thinkrank / includes / admin / class-webroot-writable-notice.php

class-webroot-writable-notice.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/admin/class-webroot-writable-notice.php

602 lines 23.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Web Root Writability Notice
4 *
5 * Tells the site owner when the WordPress root cannot be written to and a
6 * feature is explicitly set to publish files there, which is the one case that
7 * stops it from being delivered (#756).
8 *
9 * @package ThinkRank\Admin
10 * @since 2.9.0
11 */
12
13 declare(strict_types=1);
14
15 namespace ThinkRank\Admin;
16
17 // Prevent direct access
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 /**
23 * Web Root Writable Notice Class
24 *
25 * Single Responsibility: surface an unwritable WordPress root to the people who
26 * can do something about it.
27 *
28 * Activation already ran this exact check and threw the answer away: the result
29 * only reached `error_log()`, and only when `WP_DEBUG` was on, so on a normal
30 * production site nobody was told. Every feature that publishes a file to the
31 * web root then failed later with a message describing the symptom rather than
32 * the cause, which is how this reached support as "sitemap generation is
33 * broken" (#753).
34 *
35 * The condition is re-evaluated live rather than read from a flag stored at
36 * activation: permissions change under a site without anyone reactivating the
37 * plugin, in both directions.
38 *
39 * @since 2.9.0
40 */
41 class Webroot_Writable_Notice {
42
43 /**
44 * Option flag storing the dismissal.
45 *
46 * Cleared whenever the root becomes writable again, so a site that breaks a
47 * second time is warned a second time instead of staying silenced forever.
48 *
49 * @var string
50 */
51 public const OPT_DISMISSED = 'thinkrank_webroot_writable_dismissed';
52
53 /**
54 * Option recording what the activation-time check saw.
55 *
56 * Not the source of truth for the notice — {@see self::root_is_writable()}
57 * is — but it lets support tell "never worked" apart from "worked until the
58 * host changed something".
59 *
60 * @var string
61 */
62 public const OPT_ACTIVATION_STATE = 'thinkrank_webroot_writable_at_activation';
63
64 /**
65 * Site Health test identifier.
66 *
67 * @var string
68 */
69 private const HEALTH_TEST = 'thinkrank_webroot_writable';
70
71 /**
72 * Nonce action for the dismiss request.
73 *
74 * @var string
75 */
76 private const NONCE_ACTION = 'thinkrank_webroot_writable_notice';
77
78 /**
79 * Initialize the notice and the Site Health test.
80 *
81 * Hooks both `admin_notices` and `thinkrank_admin_notices` for the reason
82 * {@see Search_Visibility_Notice::init()} documents: Manager
83 * ::remove_admin_notice() strips every `admin_notices` callback on
84 * ThinkRank's own screens and re-fires `thinkrank_admin_notices` instead.
85 *
86 * @return void
87 */
88 public function init(): void {
89 add_action('admin_notices', [$this, 'render']);
90 add_action('thinkrank_admin_notices', [$this, 'render']);
91 add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
92 add_action('wp_ajax_thinkrank_dismiss_webroot_writable', [$this, 'ajax_dismiss']);
93
94 add_filter('site_status_tests', [$this, 'register_health_test']);
95
96 // A site that was fixed should be warned again if it breaks a second
97 // time, so the dismissal is cleared the moment the condition clears.
98 // Only ever a delete_option() on an already-good site, so it costs
99 // nothing on the path that matters.
100 add_action('admin_init', [$this, 'reset_dismissal']);
101 }
102
103 /**
104 * Is the WordPress root writable by the process serving this request?
105 *
106 * `wp_is_writable()` rather than `is_writable()`: on Windows the latter
107 * reports a directory writable that a write then fails on, which is the
108 * whole failure mode this notice exists to name.
109 *
110 * @since 2.9.0
111 *
112 * @return bool
113 */
114 public static function root_is_writable(): bool {
115 return wp_is_writable(ABSPATH);
116 }
117
118 /**
119 * Features that genuinely stop working when the root cannot be written.
120 *
121 * Derived from what is actually unavailable rather than from a fixed list.
122 * The fixed list named robots.txt, llms.txt and the Instant Indexing key
123 * file, and on a read-only root all three still work — each has a PHP path
124 * that answers the request:
125 *
126 * - robots.txt through the `robots_txt` filter. Core only runs do_robots()
127 * when no physical file exists, so an unwritable root is precisely the
128 * case where the filter does answer. The file is an optional extra.
129 * - the Instant Indexing key through `maybe_serve_key_file()` on
130 * `parse_request` when no file exists (#243 / #247).
131 * - llms.txt through `serve_llms_txt()`, whose `auto` mode now resolves to
132 * dynamic on an unwritable root (#756).
133 *
134 * That mattered because on a managed host such as Flywheel, ABSPATH is the
135 * locked core folder (`/www/.wordpress/`) while the document root (`/www`)
136 * is writable — so the notice fired permanently, naming three features that
137 * were working, and told the user to ask the host to change something the
138 * host locks by design.
139 *
140 * What remains genuinely broken is a feature explicitly set to write files
141 * on a root that cannot be written. `auto` never lands there any more.
142 *
143 * Kept in one place so the notice and the Site Health test cannot drift.
144 *
145 * @since 2.9.0
146 * @since 2.10.0 Reports only what is actually unavailable (#756).
147 *
148 * @return string[] Human labels of features that cannot be delivered.
149 */
150 private static function affected_features(): array {
151 return self::labels_for(self::feature_states()['affected']);
152 }
153
154 /**
155 * Features that keep working on the unwritable root, for the reassurance.
156 *
157 * The complement of {@see self::affected_features()}, derived rather than
158 * written out. The sentence used to be a fixed string naming robots.txt,
159 * llms.txt and the Instant Indexing key, so with llms.txt forced to write
160 * files the notice said "ThinkRank cannot publish llms.txt" and, one line
161 * later, that llms.txt was unaffected and kept working.
162 *
163 * A feature that is switched off is in neither list: it publishes nothing,
164 * so it is not failing, but "ThinkRank serves it from WordPress" would not
165 * be true of it either.
166 *
167 * @since 2.10.0
168 *
169 * @return string[] Human labels of features still being delivered.
170 */
171 private static function unaffected_features(): array {
172 return self::labels_for(self::feature_states()['unaffected']);
173 }
174
175 /**
176 * Every feature that is switched on, whatever its delivery.
177 *
178 * For the writable-root pass, where nothing is failing and the question is
179 * only what the folder is used for.
180 *
181 * @since 2.10.0
182 *
183 * @return string[] Human labels.
184 */
185 private static function enabled_features(): array {
186 $states = self::feature_states();
187
188 return self::labels_for(array_merge($states['affected'], $states['unaffected']));
189 }
190
191 /**
192 * Join feature labels into a readable, localised list.
193 *
194 * wp_sprintf_l() rather than implode(): the lists are now built at run
195 * time, and "robots.txt, llms.txt, the Instant Indexing key" with no
196 * conjunction read as a sentence that had been cut short.
197 *
198 * @since 2.10.0
199 *
200 * @param string[] $labels Human labels.
201 * @return string
202 */
203 private static function list_text(array $labels): string {
204 return wp_sprintf_l('%l', $labels);
205 }
206
207 /**
208 * Human labels for a set of feature keys, in feature_labels() order.
209 *
210 * @since 2.10.0
211 *
212 * @param string[] $keys Feature keys.
213 * @return string[]
214 */
215 private static function labels_for(array $keys): array {
216 return array_values(array_intersect_key(self::feature_labels(), array_flip($keys)));
217 }
218
219 /**
220 * Every file-backed feature this class reports on, keyed for the lists.
221 *
222 * The one place the feature names are written, so the failure sentence,
223 * the reassurance and the Site Health pass text cannot name different sets.
224 *
225 * @since 2.10.0
226 *
227 * @return array<string,string> Feature key => human label.
228 */
229 private static function feature_labels(): array {
230 return [
231 'robots' => __('robots.txt', 'thinkrank'),
232 'llms' => __('llms.txt', 'thinkrank'),
233 'indexnow' => __('the Instant Indexing key', 'thinkrank'),
234 'sitemap' => __('the XML sitemap', 'thinkrank'),
235 ];
236 }
237
238 /**
239 * Sort every feature into affected, unaffected, or switched off.
240 *
241 * robots.txt and the Instant Indexing key always have a PHP path, so they
242 * are never affected (see {@see self::affected_features()}) and have no
243 * delivery setting that could make them so.
244 *
245 * @since 2.10.0
246 *
247 * @return array{affected: string[], unaffected: string[]} Feature keys.
248 */
249 private static function feature_states(): array {
250 $states = [
251 'affected' => [],
252 'unaffected' => ['robots', 'indexnow'],
253 ];
254
255 $llms = self::delivery_state('ThinkRank\\SEO\\LLMs_Txt_Manager');
256 if (null !== $llms) {
257 $states[$llms][] = 'llms';
258 }
259
260 $sitemap = self::sitemap_state();
261 if (null !== $sitemap) {
262 $states[$sitemap][] = 'sitemap';
263 }
264
265 return $states;
266 }
267
268 /**
269 * Is this manager's delivery explicitly set to write files?
270 *
271 * Only an explicit `static` counts. `auto` resolving to static means the
272 * root IS writable, in which case none of this applies.
273 *
274 * @since 2.10.0
275 * @since 2.10.0 Returns the feature's state rather than a bool, so a
276 * switched-off feature can be left out of both lists.
277 *
278 * @param string $manager_class Fully-qualified manager class name.
279 * @return string|null 'affected', 'unaffected', or null when switched off.
280 */
281 private static function delivery_state(string $manager_class): ?string {
282 if (!class_exists($manager_class)) {
283 return null;
284 }
285
286 $manager = new $manager_class();
287 $settings = $manager->get_settings('site');
288
289 if (empty($settings['enabled'])) {
290 // A feature that is switched off publishes nothing, so it cannot be
291 // failing to publish.
292 return null;
293 }
294
295 return 'static' === (string) ($settings['delivery_mode'] ?? 'auto') ? 'affected' : 'unaffected';
296 }
297
298 /**
299 * Is the XML sitemap genuinely unharmed by the unwritable root?
300 *
301 * Only when delivery resolves to dynamic. On `auto` — the default — an
302 * unwritable root resolves that way by itself, so the reassurance is
303 * normally true. It stops being true the moment someone explicitly picks
304 * "write files", and stating it unconditionally told those users to ignore
305 * a notice that was in fact reporting a broken sitemap.
306 *
307 * @since 2.9.0
308 * @since 2.10.0 Returns the state rather than a bool; see delivery_state().
309 *
310 * @return string|null 'affected', 'unaffected', or null when switched off.
311 */
312 private static function sitemap_state(): ?string {
313 if (!class_exists('ThinkRank\\SEO\\Sitemap_Generator')) {
314 return null;
315 }
316
317 $sitemap = new \ThinkRank\SEO\Sitemap_Generator(false);
318
319 // Same rule delivery_state() applies to llms.txt: a feature that is
320 // switched off publishes nothing, so it cannot be failing to publish.
321 // Without this a site with the sitemap disabled and a stale
322 // `delivery_mode` of `static` gets the permanent notice back, which is
323 // the bug this class was rewritten to stop (#756).
324 if (empty($sitemap->get_settings('site')['enabled'])) {
325 return null;
326 }
327
328 return 'dynamic' === $sitemap->resolve_delivery_mode() ? 'unaffected' : 'affected';
329 }
330
331 /**
332 * Whether the notice should render on this request.
333 *
334 * @return bool
335 */
336 private function should_display(): bool {
337 if (self::root_is_writable()) {
338 return false;
339 }
340
341 // An unwritable root is not itself a problem. Every file feature has a
342 // PHP path, and `auto` uses it, so there is nothing to report unless a
343 // feature is explicitly set to write files. Warning regardless is what
344 // made this permanent on hosts that lock the core folder by design and
345 // will not be unlocking it (#756).
346 if (empty(self::affected_features())) {
347 return false;
348 }
349
350 // Only users who can act on it (or ask the host to) are shown the
351 // warning.
352 if (!current_user_can('manage_options')) {
353 return false;
354 }
355
356 return !get_option(self::OPT_DISMISSED);
357 }
358
359 /**
360 * Load the shared notice stylesheet when the notice will render.
361 *
362 * @return void
363 */
364 public function enqueue_assets(): void {
365 if (!$this->should_display()) {
366 return;
367 }
368
369 wp_enqueue_style(
370 'thinkrank-admin-notices',
371 THINKRANK_PLUGIN_URL . 'static/css/admin-notices.css',
372 [],
373 THINKRANK_VERSION
374 );
375 }
376
377 /**
378 * Render the notice.
379 *
380 * @return void
381 */
382 public function render(): void {
383 if (!$this->should_display()) {
384 return;
385 }
386
387 ?>
388 <div class="notice notice-warning is-dismissible thinkrank-notice thinkrank-webroot-writable-notice">
389 <div class="thinkrank-notice__inner">
390 <div class="thinkrank-notice__body">
391 <p class="thinkrank-notice__title"><?php esc_html_e('ThinkRank cannot write to your WordPress folder', 'thinkrank'); ?></p>
392 <p class="thinkrank-notice__text">
393 <?php
394 printf(
395 /* translators: 1: list of affected features, 2: absolute path to the WordPress root. */
396 esc_html__('ThinkRank cannot publish %1$s. Its delivery is set to write files, and the folder %2$s is not writable by PHP. Set delivery to Automatic and ThinkRank will serve it directly. Asking your host to make the folder writable also works, though some managed hosts lock it deliberately.', 'thinkrank'),
397 esc_html(self::list_text(self::affected_features())),
398 '<code>' . esc_html(untrailingslashit(ABSPATH)) . '</code>'
399 );
400 ?>
401 </p>
402 <p class="thinkrank-notice__text">
403 <?php
404 printf(
405 /* translators: %s: list of features that keep working. */
406 esc_html__('Everything else is unaffected. ThinkRank serves %s from WordPress when there is no file to read, so those keep working on a read-only folder.', 'thinkrank'),
407 esc_html(self::list_text(self::unaffected_features()))
408 );
409 ?>
410 </p>
411 <p class="thinkrank-notice__actions">
412 <a href="<?php echo esc_url(admin_url('site-health.php')); ?>" class="button button-primary">
413 <?php esc_html_e('Check Site Health', 'thinkrank'); ?>
414 </a>
415 <a href="#" class="thinkrank-notice__dismiss thinkrank-dismiss-webroot-writable" data-nonce="<?php echo esc_attr(wp_create_nonce(self::NONCE_ACTION)); ?>">
416 <?php esc_html_e('Dismiss', 'thinkrank'); ?>
417 </a>
418 </p>
419 </div>
420 </div>
421 </div>
422 <?php
423 // Same reasoning as Search_Visibility_Notice: this renders on every
424 // admin screen, so the dismiss handler ships with it rather than in the
425 // thinkrank-admin bundle, which only loads on ThinkRank pages.
426 wp_print_inline_script_tag(
427 '( function () {
428 document.addEventListener( "click", function ( event ) {
429 var notice = event.target.closest( ".thinkrank-webroot-writable-notice" );
430 if ( ! notice ) {
431 return;
432 }
433 var link = event.target.closest( ".thinkrank-dismiss-webroot-writable" );
434 if ( ! link && ! event.target.closest( ".notice-dismiss" ) ) {
435 return;
436 }
437 if ( link ) {
438 event.preventDefault();
439 notice.style.display = "none";
440 }
441 window.fetch( window.ajaxurl, {
442 method: "POST",
443 credentials: "same-origin",
444 body: new URLSearchParams( {
445 action: "thinkrank_dismiss_webroot_writable",
446 nonce: notice.querySelector( ".thinkrank-dismiss-webroot-writable" ).dataset.nonce,
447 } ),
448 } );
449 } );
450 } )();'
451 );
452 }
453
454 /**
455 * AJAX handler persisting the dismissal.
456 *
457 * @return void
458 */
459 public function ajax_dismiss(): void {
460 check_ajax_referer(self::NONCE_ACTION, 'nonce');
461
462 // The nonce proves intent, not authorization — dismissing a site-wide
463 // notice writes an option, so require the same capability that renders
464 // it.
465 if (!current_user_can('manage_options')) {
466 wp_send_json_error('Insufficient permissions', 403);
467 }
468
469 update_option(self::OPT_DISMISSED, 1, true);
470
471 wp_send_json_success();
472 }
473
474 /**
475 * Register the Site Health test.
476 *
477 * Direct rather than async: the check is a single stat() call, so there is
478 * nothing to gain from a second request.
479 *
480 * @since 2.9.0
481 *
482 * No return type: Site Health hands this filter whatever earlier callbacks
483 * returned, and a non-array means something upstream is misbehaving.
484 * Replacing it with our own array would silently drop every other plugin's
485 * tests, so it is passed through exactly as received.
486 *
487 * @param array $tests Registered Site Health tests.
488 * @return array|mixed
489 */
490 public function register_health_test($tests) {
491 if (!is_array($tests)) {
492 return $tests;
493 }
494
495 $tests['direct'][self::HEALTH_TEST] = [
496 'label' => __('ThinkRank can publish files to your WordPress folder', 'thinkrank'),
497 'test' => [$this, 'run_health_test'],
498 ];
499
500 return $tests;
501 }
502
503 /**
504 * Site Health test body.
505 *
506 * @since 2.9.0
507 *
508 * @return array Site Health result array.
509 */
510 public function run_health_test(): array {
511 $result = [
512 'label' => __('ThinkRank can publish files to your WordPress folder', 'thinkrank'),
513 'status' => 'good',
514 'badge' => [
515 'label' => __('SEO', 'thinkrank'),
516 'color' => 'blue',
517 ],
518 'description' => '<p>' . sprintf(
519 /* translators: %s: list of features that can publish files. */
520 esc_html__('ThinkRank can write to the WordPress root, so %s can be published as files.', 'thinkrank'),
521 esc_html(self::list_text(self::enabled_features()))
522 ) . '</p>',
523 'actions' => '',
524 'test' => self::HEALTH_TEST,
525 ];
526
527 if (self::root_is_writable()) {
528 return $result;
529 }
530
531 // The root is read-only, but that alone is not a fault: every file
532 // feature has a PHP path and `auto` uses it. Report a pass, and say so,
533 // rather than a permanent "recommended" on hosts that lock the folder
534 // by design (#756).
535 if (empty(self::affected_features())) {
536 $result['label'] = __('ThinkRank serves its files from WordPress', 'thinkrank');
537 $result['description'] = '<p>' . sprintf(
538 /* translators: 1: absolute path to the WordPress root, 2: list of features served from WordPress. */
539 esc_html__('The folder %1$s is not writable by PHP, which is normal on managed hosts that keep the WordPress core folder read-only. Nothing is affected: ThinkRank serves %2$s directly from WordPress when it cannot write them to disk.', 'thinkrank'),
540 '<code>' . esc_html(untrailingslashit(ABSPATH)) . '</code>',
541 esc_html(self::list_text(self::unaffected_features()))
542 ) . '</p>';
543
544 return $result;
545 }
546
547 $result['status'] = 'recommended';
548 $result['label'] = __('ThinkRank cannot publish files to your WordPress folder', 'thinkrank');
549
550 $description = '<p>' . sprintf(
551 /* translators: 1: list of affected features, 2: absolute path to the WordPress root. */
552 esc_html__('ThinkRank cannot publish %1$s. Its delivery is set to write files, and the folder %2$s is not writable by PHP.', 'thinkrank'),
553 esc_html(self::list_text(self::affected_features())),
554 '<code>' . esc_html(untrailingslashit(ABSPATH)) . '</code>'
555 ) . '</p>';
556
557 // The feature-level remedy comes first: it is the one the user can
558 // actually apply. Some managed hosts lock this folder deliberately, so
559 // "ask your host" is the fallback, not the headline (#756).
560 $description .= '<p>' . esc_html__('Set delivery to Automatic and ThinkRank will serve it directly from WordPress, with no file to write. Making the folder writable also works, though some managed hosts keep it read-only by design.', 'thinkrank') . '</p>';
561
562 // Built from what is actually still working rather than written out:
563 // the fixed sentence named llms.txt as unaffected directly under a
564 // paragraph saying llms.txt could not be published.
565 $description .= '<p>' . sprintf(
566 /* translators: %s: list of features that keep working. */
567 esc_html__('Everything else is unaffected: ThinkRank serves %s from WordPress when there is no file to read.', 'thinkrank'),
568 esc_html(self::list_text(self::unaffected_features()))
569 ) . '</p>';
570
571 // Named explicitly because both are the usual first guesses and neither
572 // has any effect here: the write fails on the root directory itself,
573 // and get_filesystem_method() still reports "direct" because with no
574 // context argument it tests wp-content, not the root.
575 $description .= '<p>' . esc_html__('Adding FS_METHOD or FTP credentials to wp-config.php will not resolve this. Ask your host to make the WordPress root writable by the web server user.', 'thinkrank') . '</p>';
576
577 if (get_option(self::OPT_ACTIVATION_STATE) === 'writable') {
578 $description .= '<p>' . esc_html__('This folder was writable when ThinkRank was activated, so something on the hosting side changed since then.', 'thinkrank') . '</p>';
579 }
580
581 $result['description'] = $description;
582
583 return $result;
584 }
585
586 /**
587 * Clear the dismissal once the root becomes writable again.
588 *
589 * Called from the Site Health test and the notice path is cheap, so this
590 * runs wherever the condition is evaluated rather than on a schedule.
591 *
592 * @since 2.9.0
593 *
594 * @return void
595 */
596 public function reset_dismissal(): void {
597 if (self::root_is_writable()) {
598 delete_option(self::OPT_DISMISSED);
599 }
600 }
601 }
602