| 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 |
|