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