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
← All changes | includes/cleanup-webroot.php +206 -16 2.1.0 → 2.9.0 View file →
@@ -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);