PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.1.0 1.10.0 1.11.0 1.12.0 All 46 releases
thinkrank / includes / cleanup-webroot.php

cleanup-webroot.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/cleanup-webroot.php

726 lines 30.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Everything ThinkRank publishes into the WordPress web root, and how to take
4 * it back out again.
5 *
6 * ThinkRank does not serve its sitemap, robots.txt or llms.txt through rewrite
7 * rules — it writes real files into `ABSPATH` and lets the web server hand them
8 * out. That makes removal a filesystem problem, not just a database one, and it
9 * has a consequence the database-only cleanup missed entirely (#510): a real
10 * file at `/sitemap_index.xml` is served before WordPress boots, so every other
11 * SEO plugin — RankMath, Squirrly, core's own `wp-sitemap.xml` — is shadowed by
12 * a file belonging to a plugin that is no longer installed. The site owner sees
13 * a blank sitemap (our XSL stylesheet 404s with the plugin gone) and no way to
14 * connect it to us.
15 *
16 * The logic lives here, as plain prefixed functions rather than a class,
17 * because `uninstall.php` runs with no autoloader — `WP_UNINSTALL_PLUGIN` loads
18 * it without the plugin — and the deactivator and the sitemap generator all
19 * need the same filename derivation. Same reasoning as
20 * {@see includes/cleanup-manifest.php}, which both removal paths already share:
21 * one copy, not three that drift.
22 *
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.
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 *
37 * @package ThinkRank
38 * @since 2.1.0
39 */
40
41 declare(strict_types=1);
42
43 if (!defined('ABSPATH')) {
44 exit;
45 }
46
47 /**
48 * The `.htaccess` marker the llms.txt charset block is written under.
49 *
50 * Mirrors `Llms_Txt_Manager::HTACCESS_MARKER`, which is private and — like
51 * everything else here — unreachable during uninstall.
52 */
53 if (!defined('THINKRANK_LLMS_HTACCESS_MARKER')) {
54 define('THINKRANK_LLMS_HTACCESS_MARKER', 'ThinkRank llms.txt');
55 }
56
57 /**
58 * The first line of every robots.txt ThinkRank generates.
59 *
60 * Mirrors `Site_Identity_Manager::robots_txt_header()`. This is the ownership
61 * test for robots.txt: a file without it predates us or belongs to someone
62 * else, and must survive our removal untouched.
63 */
64 if (!defined('THINKRANK_ROBOTS_HEADER')) {
65 define('THINKRANK_ROBOTS_HEADER', '# Robots.txt generated by ThinkRank SEO');
66 }
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
127 if (!function_exists('thinkrank_webroot_primary_sitemap_filename')) {
128 /**
129 * The sitemap file the site publishes for the given settings.
130 *
131 * Index mode serves the index (`sitemap_index.xml`); the default single-file
132 * mode serves `sitemap.xml`. A configured `sitemap_urls` entry matching the
133 * current mode wins over both.
134 *
135 * @since 2.1.0
136 *
137 * @param array $settings Sitemap settings.
138 * @return string Sitemap filename.
139 */
140 function thinkrank_webroot_primary_sitemap_filename(array $settings): string {
141 $use_index = !empty($settings['use_sitemap_index']);
142
143 foreach ((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []) as $config) {
144 if (empty($config['enabled']) || empty($config['url'])) {
145 continue;
146 }
147
148 if ($use_index !== (($config['type'] ?? '') === 'index')) {
149 continue;
150 }
151
152 $path = wp_parse_url($config['url'], PHP_URL_PATH);
153 if (!empty($path)) {
154 return basename($path);
155 }
156 }
157
158 return $use_index ? 'sitemap_index.xml' : 'sitemap.xml';
159 }
160 }
161
162 if (!function_exists('thinkrank_webroot_segment_filenames')) {
163 /**
164 * Every child-sitemap filename this site could have published.
165 *
166 * Formats the configured url pattern against each type ThinkRank segments
167 * by — the four built-ins plus every public custom post type and public
168 * custom taxonomy — ignoring whether that type is currently included. The
169 * point is to recognise our own filenames, and a type that was published
170 * and later disabled still left a file behind.
171 *
172 * During uninstall the plugin is not loaded, so post types and taxonomies
173 * registered by ThinkRank itself are absent from these lists. The built-ins
174 * and every other plugin's public types are still registered, which covers
175 * what a sitemap actually segments by.
176 *
177 * @since 2.1.0
178 *
179 * @param array $settings Sitemap settings (read for `custom_url_pattern`).
180 * @return string[] Basenames, e.g. ['sitemap-posts.xml', 'sitemap-pages.xml'].
181 */
182 function thinkrank_webroot_segment_filenames(array $settings): array {
183 $pattern = (string) ($settings['custom_url_pattern'] ?? 'sitemap-{type}.xml');
184 if (strpos($pattern, '{type}') === false) {
185 return [];
186 }
187
188 $types = ['posts', 'pages', 'categories', 'tags'];
189
190 foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) {
191 $types[] = (string) $cpt;
192 }
193
194 foreach (get_taxonomies(['public' => true, '_builtin' => false], 'names') as $taxonomy) {
195 $types[] = (string) $taxonomy;
196 }
197
198 $names = [];
199 foreach (array_unique($types) as $type) {
200 $name = basename(str_replace('{type}', $type, $pattern));
201 if ($name !== '') {
202 $names[] = $name;
203 }
204 }
205
206 return $names;
207 }
208 }
209
210 if (!function_exists('thinkrank_webroot_sitemap_filenames')) {
211 /**
212 * Every sitemap basename the current settings say ThinkRank publishes.
213 *
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.
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 *
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.
231 *
232 * @param array $settings Sitemap settings.
233 * @return string[] Unique, non-empty basenames.
234 */
235 function thinkrank_webroot_sitemap_filenames(array $settings): array {
236 $names = [
237 'local-sitemap.xml',
238 thinkrank_webroot_primary_sitemap_filename($settings),
239 ];
240
241 foreach ((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []) as $config) {
242 if (empty($config['url'])) {
243 continue;
244 }
245 $name = basename((string) wp_parse_url($config['url'], PHP_URL_PATH));
246 if ($name !== '') {
247 $names[] = $name;
248 }
249 }
250
251 $names = array_merge($names, thinkrank_webroot_segment_filenames($settings));
252
253 return array_values(array_unique(array_filter($names)));
254 }
255 }
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
346 if (!function_exists('thinkrank_webroot_delete_sitemaps')) {
347 /**
348 * Remove every static sitemap file ThinkRank publishes to the web root.
349 *
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.
357 *
358 * @since 2.1.0
359 * @since 2.1.1 Each candidate must pass a content ownership test.
360 *
361 * @param array $settings Sitemap settings to derive the names from.
362 * @return array{deleted: string[], failed: string[]} Basenames removed, and
363 * those that existed but
364 * could not be removed.
365 */
366 function thinkrank_webroot_delete_sitemaps(array $settings): array {
367 $deleted = [];
368 $failed = [];
369
370 // name => whether the current settings derive it. Safety-net names are
371 // added second and never upgrade a derived one.
372 $targets = [];
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
385 // Remove the file itself and any paginated -N variants of its stem
386 // (e.g. seo-posts.xml plus seo-posts-2.xml, seo-posts-3.xml…).
387 $path = ABSPATH . $name;
388 if (file_exists($path) && thinkrank_webroot_sitemap_is_ours($path, $settings, $name_derived)) {
389 wp_delete_file($path);
390 // wp_delete_file() returns nothing, so confirm by re-checking.
391 if (file_exists($path)) {
392 $failed[] = $name;
393 } else {
394 $deleted[] = $name;
395 }
396 }
397
398 if (!preg_match('/^(.*)\.xml$/i', $name, $m)) {
399 continue;
400 }
401
402 // Pagination pages only — a numeric suffix on this exact stem.
403 // Globbing '<stem>-*.xml' matched any name that merely started with
404 // the stem, so the default 'sitemap.xml' entry pulled in every
405 // sitemap-*.xml in the root, including another plugin's.
406 $paged_pattern = '/^' . preg_quote($m[1], '/') . '-\d+\.xml$/i';
407
408 foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) {
409 $paged_name = basename($paged);
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)) {
417 continue;
418 }
419
420 wp_delete_file($paged);
421 if (file_exists($paged)) {
422 $failed[] = $paged_name;
423 } else {
424 $deleted[] = $paged_name;
425 }
426 }
427 }
428
429 return [
430 'deleted' => array_values(array_unique($deleted)),
431 'failed' => array_values(array_unique($failed)),
432 ];
433 }
434 }
435
436 if (!function_exists('thinkrank_webroot_read_sitemap_settings')) {
437 /**
438 * Read the saved site sitemap settings straight from the settings table.
439 *
440 * The removal paths cannot go through `Sitemap_Generator::get_settings()` —
441 * uninstall has no autoloader, and by deactivation time we still want the
442 * values but not the hook wiring an instance brings. Reading the four
443 * columns directly is enough, and an empty array is a safe answer: the
444 * derivation functions fall back to ThinkRank's default filenames, which is
445 * exactly the set a site that never customised anything published.
446 *
447 * @since 2.1.0
448 *
449 * @return array Sitemap settings, or an empty array when unreadable.
450 */
451 function thinkrank_webroot_read_sitemap_settings(): array {
452 global $wpdb;
453
454 if (!isset($wpdb) || !is_object($wpdb)) {
455 return [];
456 }
457
458 $table = $wpdb->prefix . 'thinkrank_seo_settings';
459
460 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Removal path; the object cache is being torn down alongside us.
461 $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
462 if ($exists !== $table) {
463 return [];
464 }
465
466 // The table name cannot be a placeholder, so it is interpolated — from
467 // $wpdb->prefix, and only after the SHOW TABLES check above matched it
468 // exactly. Every value is a placeholder.
469 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, verified above.
470 $sql = sprintf(
471 'SELECT setting_key, setting_value FROM `%s` WHERE context_type = %%s AND context_id = %%d AND setting_category = %%s AND is_active = 1',
472 $table
473 );
474
475 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Removal path; the object cache is being torn down alongside us.
476 $rows = $wpdb->get_results(
477 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $sql carries the three placeholders the sniff cannot see through sprintf().
478 $wpdb->prepare($sql, 'site', 0, 'sitemap'),
479 ARRAY_A
480 );
481
482 if (!is_array($rows)) {
483 return [];
484 }
485
486 $settings = [];
487 foreach ($rows as $row) {
488 $settings[$row['setting_key']] = maybe_unserialize($row['setting_value']);
489 }
490
491 return $settings;
492 }
493 }
494
495 if (!function_exists('thinkrank_webroot_delete_robots_txt')) {
496 /**
497 * Remove `ABSPATH/robots.txt`, but only when ThinkRank wrote it.
498 *
499 * A robots.txt that predates ThinkRank — or that another plugin owns — has
500 * no generated header, and deleting it would destroy crawl rules we never
501 * created. The header check is the whole safety story here, so an
502 * unreadable file is treated as not-ours and kept.
503 *
504 * The stored `robots_txt_content` setting is untouched: on reinstall or
505 * reactivation the file is rebuilt from it, so nothing the user typed is
506 * lost by removing the artifact.
507 *
508 * @since 2.1.0
509 *
510 * @return array{deleted: string[], failed: string[]}
511 */
512 function thinkrank_webroot_delete_robots_txt(): array {
513 $path = ABSPATH . 'robots.txt';
514
515 if (!file_exists($path) || !is_readable($path)) {
516 return ['deleted' => [], 'failed' => []];
517 }
518
519 // Only the header line is needed; a robots.txt large enough for the rest
520 // to matter is still ours or still not.
521 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a local web-root file during removal; WP_Filesystem would need credentials on some hosts.
522 $head = (string) file_get_contents($path, false, null, 0, 128);
523
524 if (strpos($head, THINKRANK_ROBOTS_HEADER) !== 0) {
525 // Someone else's file. Leave it exactly as it is.
526 return ['deleted' => [], 'failed' => []];
527 }
528
529 wp_delete_file($path);
530
531 return file_exists($path)
532 ? ['deleted' => [], 'failed' => ['robots.txt']]
533 : ['deleted' => ['robots.txt'], 'failed' => []];
534 }
535 }
536
537 if (!function_exists('thinkrank_webroot_remove_htaccess_block')) {
538 /**
539 * Strip a `# BEGIN <marker> … # END <marker>` block from `ABSPATH/.htaccess`.
540 *
541 * Strips the block outright rather than calling `insert_with_markers()` with
542 * an empty insertion — that leaves the BEGIN/END markers behind as litter.
543 *
544 * @since 2.1.0
545 *
546 * @param string $marker The marker name, without the BEGIN/END words.
547 * @return bool True when a block was found and removed.
548 */
549 function thinkrank_webroot_remove_htaccess_block(string $marker): bool {
550 $htaccess = ABSPATH . '.htaccess';
551
552 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- A writability probe, not a write; WP_Filesystem would need credentials on some hosts.
553 if (!file_exists($htaccess) || !is_readable($htaccess) || !is_writable($htaccess)) {
554 return false;
555 }
556
557 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local web-root file during removal; WP_Filesystem would need credentials on some hosts.
558 $contents = file_get_contents($htaccess);
559 if (!is_string($contents) || strpos($contents, '# BEGIN ' . $marker) === false) {
560 return false;
561 }
562
563 $quoted = preg_quote($marker, '/');
564 $cleaned = preg_replace(
565 '/\R*# BEGIN ' . $quoted . '.*?# END ' . $quoted . '[ \t]*\R?/s',
566 '',
567 $contents
568 );
569
570 if (!is_string($cleaned)) {
571 return false;
572 }
573
574 // A file left holding nothing but our (now removed) block was ours to
575 // begin with — a pre-existing .htaccess would still have content.
576 if (trim($cleaned) === '') {
577 wp_delete_file($htaccess);
578 return !file_exists($htaccess);
579 }
580
581 // Keep the file newline-terminated after the block is cut out.
582 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents,WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Local web-root file during removal; WP_Filesystem would need credentials on some hosts.
583 return false !== file_put_contents($htaccess, rtrim($cleaned, "\r\n") . "\n");
584 }
585 }
586
587 if (!function_exists('thinkrank_webroot_delete_llms_txt')) {
588 /**
589 * Remove `ABSPATH/llms.txt` and its `.htaccess` charset block.
590 *
591 * Ownership is recorded rather than sniffed: the file is only removed when
592 * `thinkrank_llms_txt_published_at` says we published one. llms.txt has no
593 * generated header to test — the document is entirely the user's prose — so
594 * a site that hand-wrote its own llms.txt before installing ThinkRank keeps
595 * it.
596 *
597 * The stored document (`thinkrank_llms_txt_content`) is left in place so a
598 * reactivation can republish it. Uninstall's own option sweep removes it
599 * afterwards when the user asked for their data to go.
600 *
601 * @since 2.1.0
602 *
603 * @return array{deleted: string[], failed: string[]}
604 */
605 function thinkrank_webroot_delete_llms_txt(): array {
606 $deleted = [];
607 $failed = [];
608
609 if (get_option('thinkrank_llms_txt_published_at', null) === null) {
610 // We have no record of publishing it — so it is not ours to remove.
611 return ['deleted' => $deleted, 'failed' => $failed];
612 }
613
614 $path = ABSPATH . 'llms.txt';
615 if (file_exists($path)) {
616 wp_delete_file($path);
617 if (file_exists($path)) {
618 $failed[] = 'llms.txt';
619 } else {
620 $deleted[] = 'llms.txt';
621 }
622 }
623
624 // The charset block only exists on Apache/LiteSpeed, and only alongside
625 // a static publish — but strip it whenever it is there, since it names
626 // a file that no longer is.
627 if (thinkrank_webroot_remove_htaccess_block(THINKRANK_LLMS_HTACCESS_MARKER)) {
628 $deleted[] = '.htaccess (llms.txt charset block)';
629 }
630
631 return ['deleted' => $deleted, 'failed' => $failed];
632 }
633 }
634
635 if (!function_exists('thinkrank_webroot_delete_indexnow_key')) {
636 /**
637 * Remove the IndexNow key file the activator drops in the web root.
638 *
639 * `Activator::setup_indexnow_key()` writes `ABSPATH/<32-hex-key>.txt`, whose
640 * name is the key itself. Only the exact filename recorded in the settings
641 * is removed — never a `*.txt` sweep — and only when it looks like a key we
642 * generated.
643 *
644 * Uninstall only, deliberately. The activator recreates this file solely
645 * when `api_key` is empty, so a deactivation that removed it would leave
646 * IndexNow silently broken after the plugin came back — and unlike the
647 * sitemap, a stray key file shadows nothing.
648 *
649 * @since 2.1.0
650 *
651 * @return array{deleted: string[], failed: string[]}
652 */
653 function thinkrank_webroot_delete_indexnow_key(): array {
654 $settings = get_option('thinkrank_instant_indexing_settings', []);
655 $key = is_array($settings) ? (string) ($settings['api_key'] ?? '') : '';
656
657 // The generated shape is bin2hex(random_bytes(16)). Anything else is not
658 // ours to delete, and this keeps a tampered option from naming an
659 // arbitrary file in the web root.
660 if (!preg_match('/^[a-f0-9]{32}$/i', $key)) {
661 return ['deleted' => [], 'failed' => []];
662 }
663
664 $name = $key . '.txt';
665 $path = ABSPATH . $name;
666
667 if (!file_exists($path)) {
668 return ['deleted' => [], 'failed' => []];
669 }
670
671 wp_delete_file($path);
672
673 return file_exists($path)
674 ? ['deleted' => [], 'failed' => [$name]]
675 : ['deleted' => [$name], 'failed' => []];
676 }
677 }
678
679 if (!function_exists('thinkrank_webroot_cleanup')) {
680 /**
681 * Remove every artifact ThinkRank published into the web root.
682 *
683 * Called from both removal paths — deactivation and uninstall — because a
684 * file that shadows the next plugin's routes does so whether ThinkRank was
685 * switched off or deleted outright. Deactivation pairs this with
686 * {@see \ThinkRank\Core\Activator::restore_webroot_artifacts()}, which puts
687 * the files back when the plugin is switched on again.
688 *
689 * Ordering note for uninstall: this has to run *before* the options and
690 * tables are dropped. Every ownership test below reads state that the
691 * database cleanup is about to remove — the sitemap filenames come from the
692 * settings table, and llms.txt ownership from an option.
693 *
694 * @since 2.1.0
695 *
696 * @param array|null $sitemap_settings Optional. Already-read sitemap
697 * settings; read from the database when
698 * omitted.
699 * @return array{deleted: string[], failed: string[]} Everything removed, and
700 * everything that was
701 * there but would not go.
702 */
703 function thinkrank_webroot_cleanup(?array $sitemap_settings = null): array {
704 $settings = $sitemap_settings ?? thinkrank_webroot_read_sitemap_settings();
705
706 $results = [
707 thinkrank_webroot_delete_sitemaps($settings),
708 thinkrank_webroot_delete_robots_txt(),
709 thinkrank_webroot_delete_llms_txt(),
710 thinkrank_webroot_delete_indexnow_key(),
711 ];
712
713 $deleted = [];
714 $failed = [];
715 foreach ($results as $result) {
716 $deleted = array_merge($deleted, $result['deleted']);
717 $failed = array_merge($failed, $result['failed']);
718 }
719
720 return [
721 'deleted' => array_values(array_unique($deleted)),
722 'failed' => array_values(array_unique($failed)),
723 ];
724 }
725 }
726