PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.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 1.1.0 1.10.0 All 48 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 2.1.0, at includes/cleanup-webroot.php

536 lines 21.2 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. 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.
27 *
28 * @package ThinkRank
29 * @since 2.1.0
30 */
31
32 declare(strict_types=1);
33
34 if (!defined('ABSPATH')) {
35 exit;
36 }
37
38 /**
39 * The `.htaccess` marker the llms.txt charset block is written under.
40 *
41 * Mirrors `Llms_Txt_Manager::HTACCESS_MARKER`, which is private and — like
42 * everything else here — unreachable during uninstall.
43 */
44 if (!defined('THINKRANK_LLMS_HTACCESS_MARKER')) {
45 define('THINKRANK_LLMS_HTACCESS_MARKER', 'ThinkRank llms.txt');
46 }
47
48 /**
49 * The first line of every robots.txt ThinkRank generates.
50 *
51 * Mirrors `Site_Identity_Manager::robots_txt_header()`. This is the ownership
52 * test for robots.txt: a file without it predates us or belongs to someone
53 * else, and must survive our removal untouched.
54 */
55 if (!defined('THINKRANK_ROBOTS_HEADER')) {
56 define('THINKRANK_ROBOTS_HEADER', '# Robots.txt generated by ThinkRank SEO');
57 }
58
59 if (!function_exists('thinkrank_webroot_primary_sitemap_filename')) {
60 /**
61 * The sitemap file the site publishes for the given settings.
62 *
63 * Index mode serves the index (`sitemap_index.xml`); the default single-file
64 * mode serves `sitemap.xml`. A configured `sitemap_urls` entry matching the
65 * current mode wins over both.
66 *
67 * @since 2.1.0
68 *
69 * @param array $settings Sitemap settings.
70 * @return string Sitemap filename.
71 */
72 function thinkrank_webroot_primary_sitemap_filename(array $settings): string {
73 $use_index = !empty($settings['use_sitemap_index']);
74
75 foreach ((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []) as $config) {
76 if (empty($config['enabled']) || empty($config['url'])) {
77 continue;
78 }
79
80 if ($use_index !== (($config['type'] ?? '') === 'index')) {
81 continue;
82 }
83
84 $path = wp_parse_url($config['url'], PHP_URL_PATH);
85 if (!empty($path)) {
86 return basename($path);
87 }
88 }
89
90 return $use_index ? 'sitemap_index.xml' : 'sitemap.xml';
91 }
92 }
93
94 if (!function_exists('thinkrank_webroot_segment_filenames')) {
95 /**
96 * Every child-sitemap filename this site could have published.
97 *
98 * Formats the configured url pattern against each type ThinkRank segments
99 * by — the four built-ins plus every public custom post type and public
100 * custom taxonomy — ignoring whether that type is currently included. The
101 * point is to recognise our own filenames, and a type that was published
102 * and later disabled still left a file behind.
103 *
104 * During uninstall the plugin is not loaded, so post types and taxonomies
105 * registered by ThinkRank itself are absent from these lists. The built-ins
106 * and every other plugin's public types are still registered, which covers
107 * what a sitemap actually segments by.
108 *
109 * @since 2.1.0
110 *
111 * @param array $settings Sitemap settings (read for `custom_url_pattern`).
112 * @return string[] Basenames, e.g. ['sitemap-posts.xml', 'sitemap-pages.xml'].
113 */
114 function thinkrank_webroot_segment_filenames(array $settings): array {
115 $pattern = (string) ($settings['custom_url_pattern'] ?? 'sitemap-{type}.xml');
116 if (strpos($pattern, '{type}') === false) {
117 return [];
118 }
119
120 $types = ['posts', 'pages', 'categories', 'tags'];
121
122 foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) {
123 $types[] = (string) $cpt;
124 }
125
126 foreach (get_taxonomies(['public' => true, '_builtin' => false], 'names') as $taxonomy) {
127 $types[] = (string) $taxonomy;
128 }
129
130 $names = [];
131 foreach (array_unique($types) as $type) {
132 $name = basename(str_replace('{type}', $type, $pattern));
133 if ($name !== '') {
134 $names[] = $name;
135 }
136 }
137
138 return $names;
139 }
140 }
141
142 if (!function_exists('thinkrank_webroot_sitemap_filenames')) {
143 /**
144 * Every sitemap basename ThinkRank could have written to the web root.
145 *
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.
151 *
152 * @since 2.1.0
153 *
154 * @param array $settings Sitemap settings.
155 * @return string[] Unique, non-empty basenames.
156 */
157 function thinkrank_webroot_sitemap_filenames(array $settings): array {
158 $names = [
159 'sitemap.xml',
160 'sitemap_index.xml',
161 'local-sitemap.xml',
162 thinkrank_webroot_primary_sitemap_filename($settings),
163 ];
164
165 foreach ((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []) as $config) {
166 if (empty($config['url'])) {
167 continue;
168 }
169 $name = basename((string) wp_parse_url($config['url'], PHP_URL_PATH));
170 if ($name !== '') {
171 $names[] = $name;
172 }
173 }
174
175 $names = array_merge($names, thinkrank_webroot_segment_filenames($settings));
176
177 return array_values(array_unique(array_filter($names)));
178 }
179 }
180
181 if (!function_exists('thinkrank_webroot_delete_sitemaps')) {
182 /**
183 * Remove every static sitemap file ThinkRank publishes to the web root.
184 *
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.
188 *
189 * @since 2.1.0
190 *
191 * @param array $settings Sitemap settings to derive the names from.
192 * @return array{deleted: string[], failed: string[]} Basenames removed, and
193 * those that existed but
194 * could not be removed.
195 */
196 function thinkrank_webroot_delete_sitemaps(array $settings): array {
197 $deleted = [];
198 $failed = [];
199
200 foreach (thinkrank_webroot_sitemap_filenames($settings) as $name) {
201 // Remove the file itself and any paginated -N variants of its stem
202 // (e.g. seo-posts.xml plus seo-posts-2.xml, seo-posts-3.xml…).
203 $path = ABSPATH . $name;
204 if (file_exists($path)) {
205 wp_delete_file($path);
206 // wp_delete_file() returns nothing, so confirm by re-checking.
207 if (file_exists($path)) {
208 $failed[] = $name;
209 } else {
210 $deleted[] = $name;
211 }
212 }
213
214 if (!preg_match('/^(.*)\.xml$/i', $name, $m)) {
215 continue;
216 }
217
218 // Pagination pages only — a numeric suffix on this exact stem.
219 // Globbing '<stem>-*.xml' matched any name that merely started with
220 // the stem, so the default 'sitemap.xml' entry pulled in every
221 // sitemap-*.xml in the root, including another plugin's.
222 $paged_pattern = '/^' . preg_quote($m[1], '/') . '-\d+\.xml$/i';
223
224 foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) {
225 $paged_name = basename($paged);
226 if (!preg_match($paged_pattern, $paged_name)) {
227 continue;
228 }
229
230 wp_delete_file($paged);
231 if (file_exists($paged)) {
232 $failed[] = $paged_name;
233 } else {
234 $deleted[] = $paged_name;
235 }
236 }
237 }
238
239 return [
240 'deleted' => array_values(array_unique($deleted)),
241 'failed' => array_values(array_unique($failed)),
242 ];
243 }
244 }
245
246 if (!function_exists('thinkrank_webroot_read_sitemap_settings')) {
247 /**
248 * Read the saved site sitemap settings straight from the settings table.
249 *
250 * The removal paths cannot go through `Sitemap_Generator::get_settings()` —
251 * uninstall has no autoloader, and by deactivation time we still want the
252 * values but not the hook wiring an instance brings. Reading the four
253 * columns directly is enough, and an empty array is a safe answer: the
254 * derivation functions fall back to ThinkRank's default filenames, which is
255 * exactly the set a site that never customised anything published.
256 *
257 * @since 2.1.0
258 *
259 * @return array Sitemap settings, or an empty array when unreadable.
260 */
261 function thinkrank_webroot_read_sitemap_settings(): array {
262 global $wpdb;
263
264 if (!isset($wpdb) || !is_object($wpdb)) {
265 return [];
266 }
267
268 $table = $wpdb->prefix . 'thinkrank_seo_settings';
269
270 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Removal path; the object cache is being torn down alongside us.
271 $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
272 if ($exists !== $table) {
273 return [];
274 }
275
276 // The table name cannot be a placeholder, so it is interpolated — from
277 // $wpdb->prefix, and only after the SHOW TABLES check above matched it
278 // exactly. Every value is a placeholder.
279 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, verified above.
280 $sql = sprintf(
281 'SELECT setting_key, setting_value FROM `%s` WHERE context_type = %%s AND context_id = %%d AND setting_category = %%s AND is_active = 1',
282 $table
283 );
284
285 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Removal path; the object cache is being torn down alongside us.
286 $rows = $wpdb->get_results(
287 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $sql carries the three placeholders the sniff cannot see through sprintf().
288 $wpdb->prepare($sql, 'site', 0, 'sitemap'),
289 ARRAY_A
290 );
291
292 if (!is_array($rows)) {
293 return [];
294 }
295
296 $settings = [];
297 foreach ($rows as $row) {
298 $settings[$row['setting_key']] = maybe_unserialize($row['setting_value']);
299 }
300
301 return $settings;
302 }
303 }
304
305 if (!function_exists('thinkrank_webroot_delete_robots_txt')) {
306 /**
307 * Remove `ABSPATH/robots.txt`, but only when ThinkRank wrote it.
308 *
309 * A robots.txt that predates ThinkRank — or that another plugin owns — has
310 * no generated header, and deleting it would destroy crawl rules we never
311 * created. The header check is the whole safety story here, so an
312 * unreadable file is treated as not-ours and kept.
313 *
314 * The stored `robots_txt_content` setting is untouched: on reinstall or
315 * reactivation the file is rebuilt from it, so nothing the user typed is
316 * lost by removing the artifact.
317 *
318 * @since 2.1.0
319 *
320 * @return array{deleted: string[], failed: string[]}
321 */
322 function thinkrank_webroot_delete_robots_txt(): array {
323 $path = ABSPATH . 'robots.txt';
324
325 if (!file_exists($path) || !is_readable($path)) {
326 return ['deleted' => [], 'failed' => []];
327 }
328
329 // Only the header line is needed; a robots.txt large enough for the rest
330 // to matter is still ours or still not.
331 // 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.
332 $head = (string) file_get_contents($path, false, null, 0, 128);
333
334 if (strpos($head, THINKRANK_ROBOTS_HEADER) !== 0) {
335 // Someone else's file. Leave it exactly as it is.
336 return ['deleted' => [], 'failed' => []];
337 }
338
339 wp_delete_file($path);
340
341 return file_exists($path)
342 ? ['deleted' => [], 'failed' => ['robots.txt']]
343 : ['deleted' => ['robots.txt'], 'failed' => []];
344 }
345 }
346
347 if (!function_exists('thinkrank_webroot_remove_htaccess_block')) {
348 /**
349 * Strip a `# BEGIN <marker> … # END <marker>` block from `ABSPATH/.htaccess`.
350 *
351 * Strips the block outright rather than calling `insert_with_markers()` with
352 * an empty insertion — that leaves the BEGIN/END markers behind as litter.
353 *
354 * @since 2.1.0
355 *
356 * @param string $marker The marker name, without the BEGIN/END words.
357 * @return bool True when a block was found and removed.
358 */
359 function thinkrank_webroot_remove_htaccess_block(string $marker): bool {
360 $htaccess = ABSPATH . '.htaccess';
361
362 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- A writability probe, not a write; WP_Filesystem would need credentials on some hosts.
363 if (!file_exists($htaccess) || !is_readable($htaccess) || !is_writable($htaccess)) {
364 return false;
365 }
366
367 // 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.
368 $contents = file_get_contents($htaccess);
369 if (!is_string($contents) || strpos($contents, '# BEGIN ' . $marker) === false) {
370 return false;
371 }
372
373 $quoted = preg_quote($marker, '/');
374 $cleaned = preg_replace(
375 '/\R*# BEGIN ' . $quoted . '.*?# END ' . $quoted . '[ \t]*\R?/s',
376 '',
377 $contents
378 );
379
380 if (!is_string($cleaned)) {
381 return false;
382 }
383
384 // A file left holding nothing but our (now removed) block was ours to
385 // begin with — a pre-existing .htaccess would still have content.
386 if (trim($cleaned) === '') {
387 wp_delete_file($htaccess);
388 return !file_exists($htaccess);
389 }
390
391 // Keep the file newline-terminated after the block is cut out.
392 // 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.
393 return false !== file_put_contents($htaccess, rtrim($cleaned, "\r\n") . "\n");
394 }
395 }
396
397 if (!function_exists('thinkrank_webroot_delete_llms_txt')) {
398 /**
399 * Remove `ABSPATH/llms.txt` and its `.htaccess` charset block.
400 *
401 * Ownership is recorded rather than sniffed: the file is only removed when
402 * `thinkrank_llms_txt_published_at` says we published one. llms.txt has no
403 * generated header to test — the document is entirely the user's prose — so
404 * a site that hand-wrote its own llms.txt before installing ThinkRank keeps
405 * it.
406 *
407 * The stored document (`thinkrank_llms_txt_content`) is left in place so a
408 * reactivation can republish it. Uninstall's own option sweep removes it
409 * afterwards when the user asked for their data to go.
410 *
411 * @since 2.1.0
412 *
413 * @return array{deleted: string[], failed: string[]}
414 */
415 function thinkrank_webroot_delete_llms_txt(): array {
416 $deleted = [];
417 $failed = [];
418
419 if (get_option('thinkrank_llms_txt_published_at', null) === null) {
420 // We have no record of publishing it — so it is not ours to remove.
421 return ['deleted' => $deleted, 'failed' => $failed];
422 }
423
424 $path = ABSPATH . 'llms.txt';
425 if (file_exists($path)) {
426 wp_delete_file($path);
427 if (file_exists($path)) {
428 $failed[] = 'llms.txt';
429 } else {
430 $deleted[] = 'llms.txt';
431 }
432 }
433
434 // The charset block only exists on Apache/LiteSpeed, and only alongside
435 // a static publish — but strip it whenever it is there, since it names
436 // a file that no longer is.
437 if (thinkrank_webroot_remove_htaccess_block(THINKRANK_LLMS_HTACCESS_MARKER)) {
438 $deleted[] = '.htaccess (llms.txt charset block)';
439 }
440
441 return ['deleted' => $deleted, 'failed' => $failed];
442 }
443 }
444
445 if (!function_exists('thinkrank_webroot_delete_indexnow_key')) {
446 /**
447 * Remove the IndexNow key file the activator drops in the web root.
448 *
449 * `Activator::setup_indexnow_key()` writes `ABSPATH/<32-hex-key>.txt`, whose
450 * name is the key itself. Only the exact filename recorded in the settings
451 * is removed — never a `*.txt` sweep — and only when it looks like a key we
452 * generated.
453 *
454 * Uninstall only, deliberately. The activator recreates this file solely
455 * when `api_key` is empty, so a deactivation that removed it would leave
456 * IndexNow silently broken after the plugin came back — and unlike the
457 * sitemap, a stray key file shadows nothing.
458 *
459 * @since 2.1.0
460 *
461 * @return array{deleted: string[], failed: string[]}
462 */
463 function thinkrank_webroot_delete_indexnow_key(): array {
464 $settings = get_option('thinkrank_instant_indexing_settings', []);
465 $key = is_array($settings) ? (string) ($settings['api_key'] ?? '') : '';
466
467 // The generated shape is bin2hex(random_bytes(16)). Anything else is not
468 // ours to delete, and this keeps a tampered option from naming an
469 // arbitrary file in the web root.
470 if (!preg_match('/^[a-f0-9]{32}$/i', $key)) {
471 return ['deleted' => [], 'failed' => []];
472 }
473
474 $name = $key . '.txt';
475 $path = ABSPATH . $name;
476
477 if (!file_exists($path)) {
478 return ['deleted' => [], 'failed' => []];
479 }
480
481 wp_delete_file($path);
482
483 return file_exists($path)
484 ? ['deleted' => [], 'failed' => [$name]]
485 : ['deleted' => [$name], 'failed' => []];
486 }
487 }
488
489 if (!function_exists('thinkrank_webroot_cleanup')) {
490 /**
491 * Remove every artifact ThinkRank published into the web root.
492 *
493 * Called from both removal paths — deactivation and uninstall — because a
494 * file that shadows the next plugin's routes does so whether ThinkRank was
495 * switched off or deleted outright. Deactivation pairs this with
496 * {@see \ThinkRank\Core\Activator::restore_webroot_artifacts()}, which puts
497 * the files back when the plugin is switched on again.
498 *
499 * Ordering note for uninstall: this has to run *before* the options and
500 * tables are dropped. Every ownership test below reads state that the
501 * database cleanup is about to remove — the sitemap filenames come from the
502 * settings table, and llms.txt ownership from an option.
503 *
504 * @since 2.1.0
505 *
506 * @param array|null $sitemap_settings Optional. Already-read sitemap
507 * settings; read from the database when
508 * omitted.
509 * @return array{deleted: string[], failed: string[]} Everything removed, and
510 * everything that was
511 * there but would not go.
512 */
513 function thinkrank_webroot_cleanup(?array $sitemap_settings = null): array {
514 $settings = $sitemap_settings ?? thinkrank_webroot_read_sitemap_settings();
515
516 $results = [
517 thinkrank_webroot_delete_sitemaps($settings),
518 thinkrank_webroot_delete_robots_txt(),
519 thinkrank_webroot_delete_llms_txt(),
520 thinkrank_webroot_delete_indexnow_key(),
521 ];
522
523 $deleted = [];
524 $failed = [];
525 foreach ($results as $result) {
526 $deleted = array_merge($deleted, $result['deleted']);
527 $failed = array_merge($failed, $result['failed']);
528 }
529
530 return [
531 'deleted' => array_values(array_unique($deleted)),
532 'failed' => array_values(array_unique($failed)),
533 ];
534 }
535 }
536