PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 1.0.2 All 50 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 2.9.0, at includes/admin/class-webroot-writable-notice.php

388 lines 14.5 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, which is
6 * what stops robots.txt, llms.txt and the Instant Indexing key file from being
7 * published.
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 stop working when the root cannot be written.
120 *
121 * Kept in one place so the notice and the Site Health test cannot drift.
122 *
123 * @since 2.9.0
124 *
125 * @return string[]
126 */
127 private static function affected_features(): array {
128 return [
129 __('robots.txt', 'thinkrank'),
130 __('llms.txt', 'thinkrank'),
131 __('the Instant Indexing key file', 'thinkrank'),
132 ];
133 }
134
135 /**
136 * Is the XML sitemap genuinely unharmed by the unwritable root?
137 *
138 * Only when delivery resolves to dynamic. On `auto` — the default — an
139 * unwritable root resolves that way by itself, so the reassurance is
140 * normally true. It stops being true the moment someone explicitly picks
141 * "write files", and stating it unconditionally told those users to ignore
142 * a notice that was in fact reporting a broken sitemap.
143 *
144 * @since 2.9.0
145 *
146 * @return bool True when the sitemap is served from PHP and needs no file.
147 */
148 private static function sitemap_is_unaffected(): bool {
149 if (!class_exists('ThinkRank\\SEO\\Sitemap_Generator')) {
150 return true;
151 }
152
153 return 'dynamic' === (new \ThinkRank\SEO\Sitemap_Generator(false))->resolve_delivery_mode();
154 }
155
156 /**
157 * Whether the notice should render on this request.
158 *
159 * @return bool
160 */
161 private function should_display(): bool {
162 if (self::root_is_writable()) {
163 return false;
164 }
165
166 // Only users who can act on it (or ask the host to) are shown the
167 // warning.
168 if (!current_user_can('manage_options')) {
169 return false;
170 }
171
172 return !get_option(self::OPT_DISMISSED);
173 }
174
175 /**
176 * Load the shared notice stylesheet when the notice will render.
177 *
178 * @return void
179 */
180 public function enqueue_assets(): void {
181 if (!$this->should_display()) {
182 return;
183 }
184
185 wp_enqueue_style(
186 'thinkrank-admin-notices',
187 THINKRANK_PLUGIN_URL . 'static/css/admin-notices.css',
188 [],
189 THINKRANK_VERSION
190 );
191 }
192
193 /**
194 * Render the notice.
195 *
196 * @return void
197 */
198 public function render(): void {
199 if (!$this->should_display()) {
200 return;
201 }
202
203 ?>
204 <div class="notice notice-warning is-dismissible thinkrank-notice thinkrank-webroot-writable-notice">
205 <div class="thinkrank-notice__inner">
206 <div class="thinkrank-notice__body">
207 <p class="thinkrank-notice__title"><?php esc_html_e('ThinkRank cannot write to your WordPress folder', 'thinkrank'); ?></p>
208 <p class="thinkrank-notice__text">
209 <?php
210 printf(
211 /* translators: 1: absolute path to the WordPress root, 2: comma-separated list of affected features. */
212 esc_html__('The folder %1$s is not writable by PHP, so ThinkRank cannot publish %2$s. Ask your host to make the WordPress root writable by the web server user.', 'thinkrank'),
213 '<code>' . esc_html(untrailingslashit(ABSPATH)) . '</code>',
214 esc_html(implode(', ', self::affected_features()))
215 );
216 ?>
217 </p>
218 <?php if (self::sitemap_is_unaffected()) : ?>
219 <p class="thinkrank-notice__text">
220 <?php esc_html_e('Your XML sitemap is not affected: ThinkRank serves it directly when the folder is not writable.', 'thinkrank'); ?>
221 </p>
222 <?php else : ?>
223 <p class="thinkrank-notice__text">
224 <?php esc_html_e('Your XML sitemap is affected too: sitemap delivery is set to write files, and those files cannot be written. Set sitemap delivery to automatic so WordPress serves the sitemap directly, or make the folder writable.', 'thinkrank'); ?>
225 </p>
226 <?php endif; ?>
227 <p class="thinkrank-notice__actions">
228 <a href="<?php echo esc_url(admin_url('site-health.php')); ?>" class="button button-primary">
229 <?php esc_html_e('Check Site Health', 'thinkrank'); ?>
230 </a>
231 <a href="#" class="thinkrank-notice__dismiss thinkrank-dismiss-webroot-writable" data-nonce="<?php echo esc_attr(wp_create_nonce(self::NONCE_ACTION)); ?>">
232 <?php esc_html_e('Dismiss', 'thinkrank'); ?>
233 </a>
234 </p>
235 </div>
236 </div>
237 </div>
238 <?php
239 // Same reasoning as Search_Visibility_Notice: this renders on every
240 // admin screen, so the dismiss handler ships with it rather than in the
241 // thinkrank-admin bundle, which only loads on ThinkRank pages.
242 wp_print_inline_script_tag(
243 '( function () {
244 document.addEventListener( "click", function ( event ) {
245 var notice = event.target.closest( ".thinkrank-webroot-writable-notice" );
246 if ( ! notice ) {
247 return;
248 }
249 var link = event.target.closest( ".thinkrank-dismiss-webroot-writable" );
250 if ( ! link && ! event.target.closest( ".notice-dismiss" ) ) {
251 return;
252 }
253 if ( link ) {
254 event.preventDefault();
255 notice.style.display = "none";
256 }
257 window.fetch( window.ajaxurl, {
258 method: "POST",
259 credentials: "same-origin",
260 body: new URLSearchParams( {
261 action: "thinkrank_dismiss_webroot_writable",
262 nonce: notice.querySelector( ".thinkrank-dismiss-webroot-writable" ).dataset.nonce,
263 } ),
264 } );
265 } );
266 } )();'
267 );
268 }
269
270 /**
271 * AJAX handler persisting the dismissal.
272 *
273 * @return void
274 */
275 public function ajax_dismiss(): void {
276 check_ajax_referer(self::NONCE_ACTION, 'nonce');
277
278 // The nonce proves intent, not authorization — dismissing a site-wide
279 // notice writes an option, so require the same capability that renders
280 // it.
281 if (!current_user_can('manage_options')) {
282 wp_send_json_error('Insufficient permissions', 403);
283 }
284
285 update_option(self::OPT_DISMISSED, 1, true);
286
287 wp_send_json_success();
288 }
289
290 /**
291 * Register the Site Health test.
292 *
293 * Direct rather than async: the check is a single stat() call, so there is
294 * nothing to gain from a second request.
295 *
296 * @since 2.9.0
297 *
298 * No return type: Site Health hands this filter whatever earlier callbacks
299 * returned, and a non-array means something upstream is misbehaving.
300 * Replacing it with our own array would silently drop every other plugin's
301 * tests, so it is passed through exactly as received.
302 *
303 * @param array $tests Registered Site Health tests.
304 * @return array|mixed
305 */
306 public function register_health_test($tests) {
307 if (!is_array($tests)) {
308 return $tests;
309 }
310
311 $tests['direct'][self::HEALTH_TEST] = [
312 'label' => __('ThinkRank can publish files to your WordPress folder', 'thinkrank'),
313 'test' => [$this, 'run_health_test'],
314 ];
315
316 return $tests;
317 }
318
319 /**
320 * Site Health test body.
321 *
322 * @since 2.9.0
323 *
324 * @return array Site Health result array.
325 */
326 public function run_health_test(): array {
327 $result = [
328 'label' => __('ThinkRank can publish files to your WordPress folder', 'thinkrank'),
329 'status' => 'good',
330 'badge' => [
331 'label' => __('SEO', 'thinkrank'),
332 'color' => 'blue',
333 ],
334 'description' => '<p>' . esc_html__('ThinkRank can write to the WordPress root, so robots.txt, llms.txt and the Instant Indexing key file can be published.', 'thinkrank') . '</p>',
335 'actions' => '',
336 'test' => self::HEALTH_TEST,
337 ];
338
339 if (self::root_is_writable()) {
340 return $result;
341 }
342
343 $result['status'] = 'recommended';
344 $result['label'] = __('ThinkRank cannot publish files to your WordPress folder', 'thinkrank');
345
346 $description = '<p>' . sprintf(
347 /* translators: 1: absolute path to the WordPress root, 2: comma-separated list of affected features. */
348 esc_html__('The folder %1$s is not writable by PHP, so ThinkRank cannot publish %2$s.', 'thinkrank'),
349 '<code>' . esc_html(untrailingslashit(ABSPATH)) . '</code>',
350 esc_html(implode(', ', self::affected_features()))
351 ) . '</p>';
352
353 $description .= self::sitemap_is_unaffected()
354 ? '<p>' . esc_html__('Your XML sitemap is not affected. ThinkRank detects this and serves the sitemap directly instead of writing it to a file.', 'thinkrank') . '</p>'
355 : '<p>' . esc_html__('Your XML sitemap is affected too. Sitemap delivery is set to write files, and those files cannot be written. Set sitemap delivery to automatic so WordPress serves the sitemap directly, or make the folder writable.', 'thinkrank') . '</p>';
356
357 // Named explicitly because both are the usual first guesses and neither
358 // has any effect here: the write fails on the root directory itself,
359 // and get_filesystem_method() still reports "direct" because with no
360 // context argument it tests wp-content, not the root.
361 $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>';
362
363 if (get_option(self::OPT_ACTIVATION_STATE) === 'writable') {
364 $description .= '<p>' . esc_html__('This folder was writable when ThinkRank was activated, so something on the hosting side changed since then.', 'thinkrank') . '</p>';
365 }
366
367 $result['description'] = $description;
368
369 return $result;
370 }
371
372 /**
373 * Clear the dismissal once the root becomes writable again.
374 *
375 * Called from the Site Health test and the notice path is cheap, so this
376 * runs wherever the condition is evaluated rather than on a schedule.
377 *
378 * @since 2.9.0
379 *
380 * @return void
381 */
382 public function reset_dismissal(): void {
383 if (self::root_is_writable()) {
384 delete_option(self::OPT_DISMISSED);
385 }
386 }
387 }
388