← All changes
|
includes/admin/class-webroot-writable-notice.php
+245
-31
2.9.0
→
2.10.0
View file →
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | 3 | * Web Root Writability Notice |
| 4 | 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. | |
| 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 | 8 | * |
| 9 | 9 | * @package ThinkRank\Admin |
| 10 | 10 | * @since 2.9.0 |
| 11 | 11 | */ |
| @@ -115,25 +115,188 @@ | ||
| 115 | 115 | return wp_is_writable(ABSPATH); |
| 116 | 116 | } |
| 117 | 117 | |
| 118 | 118 | /** |
| 119 | - * Features that stop working when the root cannot be written. | |
| 119 | + * Features that genuinely stop working when the root cannot be written. | |
| 120 | 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 | + * | |
| 121 | 143 | * Kept in one place so the notice and the Site Health test cannot drift. |
| 122 | 144 | * |
| 123 | 145 | * @since 2.9.0 |
| 146 | + * @since 2.10.0 Reports only what is actually unavailable (#756). | |
| 124 | 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. | |
| 125 | 213 | * @return string[] |
| 126 | 214 | */ |
| 127 | - private static function affected_features(): array { | |
| 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 { | |
| 128 | 230 | return [ |
| 129 | - __('robots.txt', 'thinkrank'), | |
| 130 | - __('llms.txt', 'thinkrank'), | |
| 131 | - __('the Instant Indexing key file', 'thinkrank'), | |
| 231 | + 'robots' => __('robots.txt', 'thinkrank'), | |
| 232 | + 'llms' => __('llms.txt', 'thinkrank'), | |
| 233 | + 'indexnow' => __('the Instant Indexing key', 'thinkrank'), | |
| 234 | + 'sitemap' => __('the XML sitemap', 'thinkrank'), | |
| 132 | 235 | ]; |
| 133 | 236 | } |
| 134 | 237 | |
| 135 | 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 | + /** | |
| 136 | 299 | * Is the XML sitemap genuinely unharmed by the unwritable root? |
| 137 | 300 | * |
| 138 | 301 | * Only when delivery resolves to dynamic. On `auto` — the default — an |
| 139 | 302 | * unwritable root resolves that way by itself, so the reassurance is |
| @@ -141,17 +304,29 @@ | ||
| 141 | 304 | * "write files", and stating it unconditionally told those users to ignore |
| 142 | 305 | * a notice that was in fact reporting a broken sitemap. |
| 143 | 306 | * |
| 144 | 307 | * @since 2.9.0 |
| 308 | + * @since 2.10.0 Returns the state rather than a bool; see delivery_state(). | |
| 145 | 309 | * |
| 146 | - * @return bool True when the sitemap is served from PHP and needs no file. | |
| 310 | + * @return string|null 'affected', 'unaffected', or null when switched off. | |
| 147 | 311 | */ |
| 148 | - private static function sitemap_is_unaffected(): bool { | |
| 312 | + private static function sitemap_state(): ?string { | |
| 149 | 313 | if (!class_exists('ThinkRank\\SEO\\Sitemap_Generator')) { |
| 150 | - return true; | |
| 314 | + return null; | |
| 151 | 315 | } |
| 152 | 316 | |
| 153 | - return 'dynamic' === (new \ThinkRank\SEO\Sitemap_Generator(false))->resolve_delivery_mode(); | |
| 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'; | |
| 154 | 329 | } |
| 155 | 330 | |
| 156 | 331 | /** |
| 157 | 332 | * Whether the notice should render on this request. |
| @@ -162,8 +337,17 @@ | ||
| 162 | 337 | if (self::root_is_writable()) { |
| 163 | 338 | return false; |
| 164 | 339 | } |
| 165 | 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 | + | |
| 166 | 350 | // Only users who can act on it (or ask the host to) are shown the |
| 167 | 351 | // warning. |
| 168 | 352 | if (!current_user_can('manage_options')) { |
| 169 | 353 | return false; |
| @@ -207,24 +391,24 @@ | ||
| 207 | 391 | <p class="thinkrank-notice__title"><?php esc_html_e('ThinkRank cannot write to your WordPress folder', 'thinkrank'); ?></p> |
| 208 | 392 | <p class="thinkrank-notice__text"> |
| 209 | 393 | <?php |
| 210 | 394 | 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())) | |
| 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>' | |
| 215 | 399 | ); |
| 216 | 400 | ?> |
| 217 | 401 | </p> |
| 218 | - <?php if (self::sitemap_is_unaffected()) : ?> | |
| 219 | 402 | <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'); ?> | |
| 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 | + ?> | |
| 221 | 410 | </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 | 411 | <p class="thinkrank-notice__actions"> |
| 228 | 412 | <a href="<?php echo esc_url(admin_url('site-health.php')); ?>" class="button button-primary"> |
| 229 | 413 | <?php esc_html_e('Check Site Health', 'thinkrank'); ?> |
| 230 | 414 | </a> |
| @@ -330,9 +514,13 @@ | ||
| 330 | 514 | 'badge' => [ |
| 331 | 515 | 'label' => __('SEO', 'thinkrank'), |
| 332 | 516 | 'color' => 'blue', |
| 333 | 517 | ], |
| 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>', | |
| 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>', | |
| 335 | 523 | 'actions' => '', |
| 336 | 524 | 'test' => self::HEALTH_TEST, |
| 337 | 525 | ]; |
| 338 | 526 | |
| @@ -339,21 +527,47 @@ | ||
| 339 | 527 | if (self::root_is_writable()) { |
| 340 | 528 | return $result; |
| 341 | 529 | } |
| 342 | 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 | + | |
| 343 | 547 | $result['status'] = 'recommended'; |
| 344 | 548 | $result['label'] = __('ThinkRank cannot publish files to your WordPress folder', 'thinkrank'); |
| 345 | 549 | |
| 346 | 550 | $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())) | |
| 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>' | |
| 351 | 555 | ) . '</p>'; |
| 352 | 556 | |
| 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>'; | |
| 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>'; | |
| 356 | 570 | |
| 357 | 571 | // Named explicitly because both are the usual first guesses and neither |
| 358 | 572 | // has any effect here: the write fails on the root directory itself, |
| 359 | 573 | // and get_filesystem_method() still reports "direct" because with no |