| @@ -19,13 +19,22 @@ | ||
| 19 | 19 | * need the same filename derivation. Same reasoning as |
| 20 | 20 | * {@see includes/cleanup-manifest.php}, which both removal paths already share: |
| 21 | 21 | * one copy, not three that drift. |
| 22 | 22 | * |
| 23 | - * Nothing here deletes a file we cannot show is ours. Sitemap names are derived | |
| 24 | - * from the stored settings (never a `sitemap*.xml` glob, which would eat another | |
| 25 | - * plugin's file); robots.txt is only removed when it carries our generated | |
| 26 | - * header; llms.txt only when we recorded publishing it. | |
| 23 | + * Nothing here deletes a file we cannot show is ours. Sitemaps must carry our | |
| 24 | + * prolog marker (names are derived from the stored settings too — never a | |
| 25 | + * `sitemap*.xml` glob — but a name is not proof, since ours are the canonical | |
| 26 | + * ones another plugin also writes, #515); robots.txt is only removed when it | |
| 27 | + * carries our generated header; llms.txt only when we recorded publishing it. | |
| 27 | 28 | * |
| 29 | + * The sitemap test is not just this file's rule, because removal is not just | |
| 30 | + * this file's job: `Sitemap_Generator` deletes on every regeneration too — | |
| 31 | + * orphaned segments, stale pagination pages, the local sitemap after the | |
| 32 | + * business identity is cleared — and those fire on a post save, not on a | |
| 33 | + * once-off deactivation. It routes all three through | |
| 34 | + * {@see thinkrank_webroot_sitemap_is_ours()} for exactly that reason; gating | |
| 35 | + * only the deactivation path left #515 reachable through the more common door. | |
| 36 | + * | |
| 28 | 37 | * @package ThinkRank |
| 29 | 38 | * @since 2.1.0 |
| 30 | 39 | */ |
| 31 | 40 | |
| @@ -55,8 +64,67 @@ | ||
| 55 | 64 | if (!defined('THINKRANK_ROBOTS_HEADER')) { |
| 56 | 65 | define('THINKRANK_ROBOTS_HEADER', '# Robots.txt generated by ThinkRank SEO'); |
| 57 | 66 | } |
| 58 | 67 | |
| 68 | +/** | |
| 69 | + * The XML comment every sitemap ThinkRank writes carries in its prolog. | |
| 70 | + * | |
| 71 | + * This is the ownership test for sitemaps, and it is deliberately independent | |
| 72 | + * of any setting: `enable_styling` can be off, `include_images` can be off, the | |
| 73 | + * file can be an index or a segment or the local-business sitemap, and the | |
| 74 | + * marker is still there. {@see Sitemap_Generator::xml_prolog()} writes it. | |
| 75 | + * | |
| 76 | + * Sitemaps need the test more than the other artifacts do, because our names | |
| 77 | + * are the canonical ones — `sitemap.xml`, `sitemap_index.xml`, | |
| 78 | + * `sitemap-posts.xml` — and RankMath, Squirrly or the site owner may well have | |
| 79 | + * a real file of their own at exactly those paths (#515). Deleting by name | |
| 80 | + * alone destroyed it. | |
| 81 | + */ | |
| 82 | +if (!defined('THINKRANK_SITEMAP_MARKER')) { | |
| 83 | + define('THINKRANK_SITEMAP_MARKER', '<!-- Generated by ThinkRank SEO -->'); | |
| 84 | +} | |
| 85 | + | |
| 86 | +/** | |
| 87 | + * The pre-2.1.1 sitemap marker: the href of our own XSL stylesheet. | |
| 88 | + * | |
| 89 | + * Sitemaps written before THINKRANK_SITEMAP_MARKER existed carry no comment, | |
| 90 | + * but the ones written with styling enabled do reference our stylesheet, and | |
| 91 | + * no other plugin has a reason to point at a path inside our plugin directory. | |
| 92 | + * Recognising it keeps those files removable. | |
| 93 | + * | |
| 94 | + * The path is the stock install directory. A site that renamed the plugin folder | |
| 95 | + * will not match its own pre-2.1.1 styled sitemaps, so those fall through to the | |
| 96 | + * fallback below like any other unmarked file. Failing to recognise our own file | |
| 97 | + * only leaves it behind; widening this to a bare `/static/xsl/` would start | |
| 98 | + * matching other plugins' files, which is the failure that matters (#515). | |
| 99 | + */ | |
| 100 | +if (!defined('THINKRANK_SITEMAP_LEGACY_MARKER')) { | |
| 101 | + define('THINKRANK_SITEMAP_LEGACY_MARKER', '/plugins/thinkrank/static/xsl/'); | |
| 102 | +} | |
| 103 | + | |
| 104 | +/** | |
| 105 | + * The option recording that no unmarked sitemap of ours can be on disk. | |
| 106 | + * | |
| 107 | + * Set in two places, both meaning the same thing: | |
| 108 | + * | |
| 109 | + * - the first time {@see Sitemap_Generator::save_sitemap_to_file()} succeeds | |
| 110 | + * on 2.1.1+, since everything this version writes carries the marker; and | |
| 111 | + * - at activation on a brand-new install | |
| 112 | + * ({@see Activator::retire_sitemap_legacy_fallback()}), which cannot have a | |
| 113 | + * pre-2.1.1 file of ours to recover in the first place. | |
| 114 | + * | |
| 115 | + * It bounds the legacy fallback below. Without the second case, "has not | |
| 116 | + * written a marked sitemap yet" conflates a legacy install awaiting recovery | |
| 117 | + * with a fresh install that simply has not generated — and on the latter the | |
| 118 | + * fallback could only ever delete another plugin's file (#515). That is not a | |
| 119 | + * momentary window: `regenerate_sitemap_from_settings()` returns early while | |
| 120 | + * the master `enabled` flag is off, so a site with sitemaps disabled never | |
| 121 | + * records a write of its own. | |
| 122 | + */ | |
| 123 | +if (!defined('THINKRANK_SITEMAP_MARKED_WRITE_OPTION')) { | |
| 124 | + define('THINKRANK_SITEMAP_MARKED_WRITE_OPTION', 'thinkrank_sitemap_marked_write'); | |
| 125 | +} | |
| 126 | + | |
| 59 | 127 | if (!function_exists('thinkrank_webroot_primary_sitemap_filename')) { |
| 60 | 128 | /** |
| 61 | 129 | * The sitemap file the site publishes for the given settings. |
| 62 | 130 | * |
| @@ -140,17 +208,27 @@ | ||
| 140 | 208 | } |
| 141 | 209 | |
| 142 | 210 | if (!function_exists('thinkrank_webroot_sitemap_filenames')) { |
| 143 | 211 | /** |
| 144 | - * Every sitemap basename ThinkRank could have written to the web root. | |
| 212 | + * Every sitemap basename the current settings say ThinkRank publishes. | |
| 145 | 213 | * |
| 146 | - * The current primary plus the two default names as a safety net (settings | |
| 147 | - * can have drifted from what is on disk), every configured `sitemap_urls` | |
| 148 | - * entry, the local-business sitemap, and every segment the url pattern can | |
| 149 | - * produce. Pagination pages are not listed — they are matched per stem at | |
| 150 | - * deletion time, where the numeric suffix can be checked. | |
| 214 | + * The primary sitemap for the configured mode, every configured | |
| 215 | + * `sitemap_urls` entry, the local-business sitemap, and every segment the | |
| 216 | + * url pattern can produce. Pagination pages are not listed — they are | |
| 217 | + * matched per stem at deletion time, where the numeric suffix can be | |
| 218 | + * checked. | |
| 151 | 219 | * |
| 220 | + * The two default names are no longer added unconditionally: with | |
| 221 | + * `use_sitemap_index` off the site cannot have just written a | |
| 222 | + * `sitemap_index.xml`, and claiming it anyway is how we came to delete | |
| 223 | + * RankMath's (#515). They moved to | |
| 224 | + * {@see thinkrank_webroot_sitemap_safety_net_filenames()}, which still | |
| 225 | + * catches files a settings change left behind, but only ever deletes one | |
| 226 | + * the contents identify as ours. | |
| 227 | + * | |
| 152 | 228 | * @since 2.1.0 |
| 229 | + * @since 2.1.1 Only names the current settings derive; the unconditional | |
| 230 | + * defaults moved to the safety net. | |
| 153 | 231 | * |
| 154 | 232 | * @param array $settings Sitemap settings. |
| 155 | 233 | * @return string[] Unique, non-empty basenames. |
| 156 | 234 | */ |
| @@ -155,10 +233,8 @@ | ||
| 155 | 233 | * @return string[] Unique, non-empty basenames. |
| 156 | 234 | */ |
| 157 | 235 | function thinkrank_webroot_sitemap_filenames(array $settings): array { |
| 158 | 236 | $names = [ |
| 159 | - 'sitemap.xml', | |
| 160 | - 'sitemap_index.xml', | |
| 161 | 237 | 'local-sitemap.xml', |
| 162 | 238 | thinkrank_webroot_primary_sitemap_filename($settings), |
| 163 | 239 | ]; |
| 164 | 240 | |
| @@ -177,17 +253,111 @@ | ||
| 177 | 253 | return array_values(array_unique(array_filter($names))); |
| 178 | 254 | } |
| 179 | 255 | } |
| 180 | 256 | |
| 257 | +if (!function_exists('thinkrank_webroot_sitemap_safety_net_filenames')) { | |
| 258 | + /** | |
| 259 | + * Names ThinkRank may have written earlier, but the current settings would | |
| 260 | + * not produce. | |
| 261 | + * | |
| 262 | + * Settings drift: a site that ran in index mode and later switched to a | |
| 263 | + * single sitemap still has a `sitemap_index.xml` on disk, and a site that | |
| 264 | + * renamed its sitemap through `sitemap_urls` still has the default name it | |
| 265 | + * published under before. #510 is precisely about such a leftover shadowing | |
| 266 | + * the next plugin's route, so the names still have to be considered — they | |
| 267 | + * just cannot be deleted on the strength of the name, because they are also | |
| 268 | + * the canonical names every other SEO plugin uses. | |
| 269 | + * {@see thinkrank_webroot_delete_sitemaps()} requires a positive content | |
| 270 | + * match for everything on this list. | |
| 271 | + * | |
| 272 | + * @since 2.1.1 | |
| 273 | + * | |
| 274 | + * @return string[] Basenames. | |
| 275 | + */ | |
| 276 | + function thinkrank_webroot_sitemap_safety_net_filenames(): array { | |
| 277 | + return ['sitemap.xml', 'sitemap_index.xml']; | |
| 278 | + } | |
| 279 | +} | |
| 280 | + | |
| 281 | +if (!function_exists('thinkrank_webroot_sitemap_is_ours')) { | |
| 282 | + /** | |
| 283 | + * Can this web-root sitemap be shown to be one ThinkRank wrote? | |
| 284 | + * | |
| 285 | + * The same stance robots.txt and llms.txt already take: a file we cannot | |
| 286 | + * prove is ours is left alone. Deleting a competitor's sitemap from the | |
| 287 | + * canonical path is the mirror image of the bug #510 reported, and just as | |
| 288 | + * damaging — it is silent, irreversible, and hits on plain deactivation | |
| 289 | + * (#515). | |
| 290 | + * | |
| 291 | + * Proof is a marker in the file's prolog: the current comment, or the XSL | |
| 292 | + * href older versions wrote when styling was enabled. | |
| 293 | + * | |
| 294 | + * The legacy fallback covers the one blind spot those markers leave — a | |
| 295 | + * pre-2.1.1 file written with `enable_styling` off carries neither. It is | |
| 296 | + * kept as narrow as it can be, and it expires: the name must be one the | |
| 297 | + * *current* settings derive (not a safety-net name), the settings must | |
| 298 | + * actually say styling is off, and this install must not yet have written a | |
| 299 | + * marked sitemap of its own. Once it has, an unmarked file at one of our | |
| 300 | + * names is by definition somebody else's. Unreadable settings, styling on, | |
| 301 | + * or a marked write already recorded all mean no fallback — an unmarked file | |
| 302 | + * then survives, which is the safe direction to fail in. | |
| 303 | + * | |
| 304 | + * A fresh install records that marker at activation without writing | |
| 305 | + * anything, so the fallback never opens on a site that has no pre-2.1.1 | |
| 306 | + * sitemap of ours to recover. | |
| 307 | + * | |
| 308 | + * Callers that delete on a routine schedule pass `$name_derived = false` and | |
| 309 | + * opt out of the fallback entirely; only the once-off removal paths | |
| 310 | + * (deactivate, uninstall) ask for it. | |
| 311 | + * | |
| 312 | + * @since 2.1.1 | |
| 313 | + * | |
| 314 | + * @param string $path Absolute path to an existing file. | |
| 315 | + * @param array $settings Sitemap settings. | |
| 316 | + * @param bool $name_derived Whether the name came from the current | |
| 317 | + * settings rather than the safety net. | |
| 318 | + * @return bool True when the file may be deleted. | |
| 319 | + */ | |
| 320 | + function thinkrank_webroot_sitemap_is_ours(string $path, array $settings, bool $name_derived): bool { | |
| 321 | + if (!is_readable($path)) { | |
| 322 | + // Cannot look inside, so cannot show it is ours. | |
| 323 | + return false; | |
| 324 | + } | |
| 325 | + | |
| 326 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local filesystem read of a fixed path; WP_Filesystem is unavailable during uninstall. | |
| 327 | + $head = (string) file_get_contents($path, false, null, 0, 1024); | |
| 328 | + | |
| 329 | + if (strpos($head, THINKRANK_SITEMAP_MARKER) !== false | |
| 330 | + || strpos($head, THINKRANK_SITEMAP_LEGACY_MARKER) !== false) { | |
| 331 | + return true; | |
| 332 | + } | |
| 333 | + | |
| 334 | + if (!$name_derived | |
| 335 | + || !array_key_exists('enable_styling', $settings) | |
| 336 | + || !empty($settings['enable_styling'])) { | |
| 337 | + return false; | |
| 338 | + } | |
| 339 | + | |
| 340 | + // The fallback only makes sense while this install has never written a | |
| 341 | + // marked sitemap. After that, unmarked means not ours. | |
| 342 | + return get_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION) !== '1'; | |
| 343 | + } | |
| 344 | +} | |
| 345 | + | |
| 181 | 346 | if (!function_exists('thinkrank_webroot_delete_sitemaps')) { |
| 182 | 347 | /** |
| 183 | 348 | * Remove every static sitemap file ThinkRank publishes to the web root. |
| 184 | 349 | * |
| 185 | - * Only ThinkRank's own filenames are targeted; WordPress core's | |
| 186 | - * `wp-sitemap.xml` and any other plugin's sitemap in the web root are left | |
| 187 | - * untouched. | |
| 350 | + * A name match is necessary but not sufficient. Our sitemap names are the | |
| 351 | + * canonical ones — `sitemap.xml`, `sitemap_index.xml`, `sitemap-posts.xml` | |
| 352 | + * — so another plugin's file sits at exactly those paths on a great many | |
| 353 | + * sites, and deleting by name alone destroyed it on plain deactivation | |
| 354 | + * (#515). Every candidate is checked against | |
| 355 | + * {@see thinkrank_webroot_sitemap_is_ours()} first; anything that cannot be | |
| 356 | + * shown to be ours is left where it is. | |
| 188 | 357 | * |
| 189 | 358 | * @since 2.1.0 |
| 359 | + * @since 2.1.1 Each candidate must pass a content ownership test. | |
| 190 | 360 | * |
| 191 | 361 | * @param array $settings Sitemap settings to derive the names from. |
| 192 | 362 | * @return array{deleted: string[], failed: string[]} Basenames removed, and |
| 193 | 363 | * those that existed but |
| @@ -196,13 +366,27 @@ | ||
| 196 | 366 | function thinkrank_webroot_delete_sitemaps(array $settings): array { |
| 197 | 367 | $deleted = []; |
| 198 | 368 | $failed = []; |
| 199 | 369 | |
| 370 | + // name => whether the current settings derive it. Safety-net names are | |
| 371 | + // added second and never upgrade a derived one. | |
| 372 | + $targets = []; | |
| 200 | 373 | foreach (thinkrank_webroot_sitemap_filenames($settings) as $name) { |
| 374 | + $targets[$name] = true; | |
| 375 | + } | |
| 376 | + foreach (thinkrank_webroot_sitemap_safety_net_filenames() as $name) { | |
| 377 | + if (!isset($targets[$name])) { | |
| 378 | + $targets[$name] = false; | |
| 379 | + } | |
| 380 | + } | |
| 381 | + | |
| 382 | + foreach ($targets as $name => $name_derived) { | |
| 383 | + $name = (string) $name; | |
| 384 | + | |
| 201 | 385 | // Remove the file itself and any paginated -N variants of its stem |
| 202 | 386 | // (e.g. seo-posts.xml plus seo-posts-2.xml, seo-posts-3.xml…). |
| 203 | 387 | $path = ABSPATH . $name; |
| 204 | - if (file_exists($path)) { | |
| 388 | + if (file_exists($path) && thinkrank_webroot_sitemap_is_ours($path, $settings, $name_derived)) { | |
| 205 | 389 | wp_delete_file($path); |
| 206 | 390 | // wp_delete_file() returns nothing, so confirm by re-checking. |
| 207 | 391 | if (file_exists($path)) { |
| 208 | 392 | $failed[] = $name; |
| @@ -223,8 +407,14 @@ | ||
| 223 | 407 | |
| 224 | 408 | foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) { |
| 225 | 409 | $paged_name = basename($paged); |
| 226 | 410 | if (!preg_match($paged_pattern, $paged_name)) { |
| 411 | + continue; | |
| 412 | + } | |
| 413 | + | |
| 414 | + // A page of our sitemap is no more ours by name than its first | |
| 415 | + // page is; another plugin paginates the same stem the same way. | |
| 416 | + if (!thinkrank_webroot_sitemap_is_ours($paged, $settings, $name_derived)) { | |
| 227 | 417 | continue; |
| 228 | 418 | } |
| 229 | 419 | |
| 230 | 420 | wp_delete_file($paged); |