'); } /** * The pre-2.1.1 sitemap marker: the href of our own XSL stylesheet. * * Sitemaps written before THINKRANK_SITEMAP_MARKER existed carry no comment, * but the ones written with styling enabled do reference our stylesheet, and * no other plugin has a reason to point at a path inside our plugin directory. * Recognising it keeps those files removable. * * The path is the stock install directory. A site that renamed the plugin folder * will not match its own pre-2.1.1 styled sitemaps, so those fall through to the * fallback below like any other unmarked file. Failing to recognise our own file * only leaves it behind; widening this to a bare `/static/xsl/` would start * matching other plugins' files, which is the failure that matters (#515). */ if (!defined('THINKRANK_SITEMAP_LEGACY_MARKER')) { define('THINKRANK_SITEMAP_LEGACY_MARKER', '/plugins/thinkrank/static/xsl/'); } /** * The option recording that no unmarked sitemap of ours can be on disk. * * Set in two places, both meaning the same thing: * * - the first time {@see Sitemap_Generator::save_sitemap_to_file()} succeeds * on 2.1.1+, since everything this version writes carries the marker; and * - at activation on a brand-new install * ({@see Activator::retire_sitemap_legacy_fallback()}), which cannot have a * pre-2.1.1 file of ours to recover in the first place. * * It bounds the legacy fallback below. Without the second case, "has not * written a marked sitemap yet" conflates a legacy install awaiting recovery * with a fresh install that simply has not generated — and on the latter the * fallback could only ever delete another plugin's file (#515). That is not a * momentary window: `regenerate_sitemap_from_settings()` returns early while * the master `enabled` flag is off, so a site with sitemaps disabled never * records a write of its own. */ if (!defined('THINKRANK_SITEMAP_MARKED_WRITE_OPTION')) { define('THINKRANK_SITEMAP_MARKED_WRITE_OPTION', 'thinkrank_sitemap_marked_write'); } if (!function_exists('thinkrank_webroot_primary_sitemap_filename')) { /** * The sitemap file the site publishes for the given settings. * * Index mode serves the index (`sitemap_index.xml`); the default single-file * mode serves `sitemap.xml`. A configured `sitemap_urls` entry matching the * current mode wins over both. * * @since 2.1.0 * * @param array $settings Sitemap settings. * @return string Sitemap filename. */ function thinkrank_webroot_primary_sitemap_filename(array $settings): string { $use_index = !empty($settings['use_sitemap_index']); foreach ((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []) as $config) { if (empty($config['enabled']) || empty($config['url'])) { continue; } if ($use_index !== (($config['type'] ?? '') === 'index')) { continue; } $path = wp_parse_url($config['url'], PHP_URL_PATH); if (!empty($path)) { return basename($path); } } return $use_index ? 'sitemap_index.xml' : 'sitemap.xml'; } } if (!function_exists('thinkrank_webroot_segment_filenames')) { /** * Every child-sitemap filename this site could have published. * * Formats the configured url pattern against each type ThinkRank segments * by — the four built-ins plus every public custom post type and public * custom taxonomy — ignoring whether that type is currently included. The * point is to recognise our own filenames, and a type that was published * and later disabled still left a file behind. * * During uninstall the plugin is not loaded, so post types and taxonomies * registered by ThinkRank itself are absent from these lists. The built-ins * and every other plugin's public types are still registered, which covers * what a sitemap actually segments by. * * @since 2.1.0 * * @param array $settings Sitemap settings (read for `custom_url_pattern`). * @return string[] Basenames, e.g. ['sitemap-posts.xml', 'sitemap-pages.xml']. */ function thinkrank_webroot_segment_filenames(array $settings): array { $pattern = (string) ($settings['custom_url_pattern'] ?? 'sitemap-{type}.xml'); if (strpos($pattern, '{type}') === false) { return []; } $types = ['posts', 'pages', 'categories', 'tags']; foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) { $types[] = (string) $cpt; } foreach (get_taxonomies(['public' => true, '_builtin' => false], 'names') as $taxonomy) { $types[] = (string) $taxonomy; } $names = []; foreach (array_unique($types) as $type) { $name = basename(str_replace('{type}', $type, $pattern)); if ($name !== '') { $names[] = $name; } } return $names; } } if (!function_exists('thinkrank_webroot_sitemap_filenames')) { /** * Every sitemap basename the current settings say ThinkRank publishes. * * The primary sitemap for the configured mode, every configured * `sitemap_urls` entry, the local-business sitemap, and every segment the * url pattern can produce. Pagination pages are not listed — they are * matched per stem at deletion time, where the numeric suffix can be * checked. * * The two default names are no longer added unconditionally: with * `use_sitemap_index` off the site cannot have just written a * `sitemap_index.xml`, and claiming it anyway is how we came to delete * RankMath's (#515). They moved to * {@see thinkrank_webroot_sitemap_safety_net_filenames()}, which still * catches files a settings change left behind, but only ever deletes one * the contents identify as ours. * * @since 2.1.0 * @since 2.1.1 Only names the current settings derive; the unconditional * defaults moved to the safety net. * * @param array $settings Sitemap settings. * @return string[] Unique, non-empty basenames. */ function thinkrank_webroot_sitemap_filenames(array $settings): array { $names = [ 'local-sitemap.xml', thinkrank_webroot_primary_sitemap_filename($settings), ]; foreach ((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []) as $config) { if (empty($config['url'])) { continue; } $name = basename((string) wp_parse_url($config['url'], PHP_URL_PATH)); if ($name !== '') { $names[] = $name; } } $names = array_merge($names, thinkrank_webroot_segment_filenames($settings)); return array_values(array_unique(array_filter($names))); } } if (!function_exists('thinkrank_webroot_sitemap_safety_net_filenames')) { /** * Names ThinkRank may have written earlier, but the current settings would * not produce. * * Settings drift: a site that ran in index mode and later switched to a * single sitemap still has a `sitemap_index.xml` on disk, and a site that * renamed its sitemap through `sitemap_urls` still has the default name it * published under before. #510 is precisely about such a leftover shadowing * the next plugin's route, so the names still have to be considered — they * just cannot be deleted on the strength of the name, because they are also * the canonical names every other SEO plugin uses. * {@see thinkrank_webroot_delete_sitemaps()} requires a positive content * match for everything on this list. * * @since 2.1.1 * * @return string[] Basenames. */ function thinkrank_webroot_sitemap_safety_net_filenames(): array { return ['sitemap.xml', 'sitemap_index.xml']; } } if (!function_exists('thinkrank_webroot_sitemap_is_ours')) { /** * Can this web-root sitemap be shown to be one ThinkRank wrote? * * The same stance robots.txt and llms.txt already take: a file we cannot * prove is ours is left alone. Deleting a competitor's sitemap from the * canonical path is the mirror image of the bug #510 reported, and just as * damaging — it is silent, irreversible, and hits on plain deactivation * (#515). * * Proof is a marker in the file's prolog: the current comment, or the XSL * href older versions wrote when styling was enabled. * * The legacy fallback covers the one blind spot those markers leave — a * pre-2.1.1 file written with `enable_styling` off carries neither. It is * kept as narrow as it can be, and it expires: the name must be one the * *current* settings derive (not a safety-net name), the settings must * actually say styling is off, and this install must not yet have written a * marked sitemap of its own. Once it has, an unmarked file at one of our * names is by definition somebody else's. Unreadable settings, styling on, * or a marked write already recorded all mean no fallback — an unmarked file * then survives, which is the safe direction to fail in. * * A fresh install records that marker at activation without writing * anything, so the fallback never opens on a site that has no pre-2.1.1 * sitemap of ours to recover. * * Callers that delete on a routine schedule pass `$name_derived = false` and * opt out of the fallback entirely; only the once-off removal paths * (deactivate, uninstall) ask for it. * * @since 2.1.1 * * @param string $path Absolute path to an existing file. * @param array $settings Sitemap settings. * @param bool $name_derived Whether the name came from the current * settings rather than the safety net. * @return bool True when the file may be deleted. */ function thinkrank_webroot_sitemap_is_ours(string $path, array $settings, bool $name_derived): bool { if (!is_readable($path)) { // Cannot look inside, so cannot show it is ours. return false; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local filesystem read of a fixed path; WP_Filesystem is unavailable during uninstall. $head = (string) file_get_contents($path, false, null, 0, 1024); if (strpos($head, THINKRANK_SITEMAP_MARKER) !== false || strpos($head, THINKRANK_SITEMAP_LEGACY_MARKER) !== false) { return true; } if (!$name_derived || !array_key_exists('enable_styling', $settings) || !empty($settings['enable_styling'])) { return false; } // The fallback only makes sense while this install has never written a // marked sitemap. After that, unmarked means not ours. return get_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION) !== '1'; } } if (!function_exists('thinkrank_webroot_delete_sitemaps')) { /** * Remove every static sitemap file ThinkRank publishes to the web root. * * A name match is necessary but not sufficient. Our sitemap names are the * canonical ones — `sitemap.xml`, `sitemap_index.xml`, `sitemap-posts.xml` * — so another plugin's file sits at exactly those paths on a great many * sites, and deleting by name alone destroyed it on plain deactivation * (#515). Every candidate is checked against * {@see thinkrank_webroot_sitemap_is_ours()} first; anything that cannot be * shown to be ours is left where it is. * * @since 2.1.0 * @since 2.1.1 Each candidate must pass a content ownership test. * * @param array $settings Sitemap settings to derive the names from. * @return array{deleted: string[], failed: string[]} Basenames removed, and * those that existed but * could not be removed. */ function thinkrank_webroot_delete_sitemaps(array $settings): array { $deleted = []; $failed = []; // name => whether the current settings derive it. Safety-net names are // added second and never upgrade a derived one. $targets = []; foreach (thinkrank_webroot_sitemap_filenames($settings) as $name) { $targets[$name] = true; } foreach (thinkrank_webroot_sitemap_safety_net_filenames() as $name) { if (!isset($targets[$name])) { $targets[$name] = false; } } foreach ($targets as $name => $name_derived) { $name = (string) $name; // Remove the file itself and any paginated -N variants of its stem // (e.g. seo-posts.xml plus seo-posts-2.xml, seo-posts-3.xml…). $path = ABSPATH . $name; if (file_exists($path) && thinkrank_webroot_sitemap_is_ours($path, $settings, $name_derived)) { wp_delete_file($path); // wp_delete_file() returns nothing, so confirm by re-checking. if (file_exists($path)) { $failed[] = $name; } else { $deleted[] = $name; } } if (!preg_match('/^(.*)\.xml$/i', $name, $m)) { continue; } // Pagination pages only — a numeric suffix on this exact stem. // Globbing '-*.xml' matched any name that merely started with // the stem, so the default 'sitemap.xml' entry pulled in every // sitemap-*.xml in the root, including another plugin's. $paged_pattern = '/^' . preg_quote($m[1], '/') . '-\d+\.xml$/i'; foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) { $paged_name = basename($paged); if (!preg_match($paged_pattern, $paged_name)) { continue; } // A page of our sitemap is no more ours by name than its first // page is; another plugin paginates the same stem the same way. if (!thinkrank_webroot_sitemap_is_ours($paged, $settings, $name_derived)) { continue; } wp_delete_file($paged); if (file_exists($paged)) { $failed[] = $paged_name; } else { $deleted[] = $paged_name; } } } return [ 'deleted' => array_values(array_unique($deleted)), 'failed' => array_values(array_unique($failed)), ]; } } if (!function_exists('thinkrank_webroot_read_sitemap_settings')) { /** * Read the saved site sitemap settings straight from the settings table. * * The removal paths cannot go through `Sitemap_Generator::get_settings()` — * uninstall has no autoloader, and by deactivation time we still want the * values but not the hook wiring an instance brings. Reading the four * columns directly is enough, and an empty array is a safe answer: the * derivation functions fall back to ThinkRank's default filenames, which is * exactly the set a site that never customised anything published. * * @since 2.1.0 * * @return array Sitemap settings, or an empty array when unreadable. */ function thinkrank_webroot_read_sitemap_settings(): array { global $wpdb; if (!isset($wpdb) || !is_object($wpdb)) { return []; } $table = $wpdb->prefix . 'thinkrank_seo_settings'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Removal path; the object cache is being torn down alongside us. $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)); if ($exists !== $table) { return []; } // The table name cannot be a placeholder, so it is interpolated — from // $wpdb->prefix, and only after the SHOW TABLES check above matched it // exactly. Every value is a placeholder. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, verified above. $sql = sprintf( 'SELECT setting_key, setting_value FROM `%s` WHERE context_type = %%s AND context_id = %%d AND setting_category = %%s AND is_active = 1', $table ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Removal path; the object cache is being torn down alongside us. $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $sql carries the three placeholders the sniff cannot see through sprintf(). $wpdb->prepare($sql, 'site', 0, 'sitemap'), ARRAY_A ); if (!is_array($rows)) { return []; } $settings = []; foreach ($rows as $row) { $settings[$row['setting_key']] = maybe_unserialize($row['setting_value']); } return $settings; } } if (!function_exists('thinkrank_webroot_delete_robots_txt')) { /** * Remove `ABSPATH/robots.txt`, but only when ThinkRank wrote it. * * A robots.txt that predates ThinkRank — or that another plugin owns — has * no generated header, and deleting it would destroy crawl rules we never * created. The header check is the whole safety story here, so an * unreadable file is treated as not-ours and kept. * * The stored `robots_txt_content` setting is untouched: on reinstall or * reactivation the file is rebuilt from it, so nothing the user typed is * lost by removing the artifact. * * @since 2.1.0 * * @return array{deleted: string[], failed: string[]} */ function thinkrank_webroot_delete_robots_txt(): array { $path = ABSPATH . 'robots.txt'; if (!file_exists($path) || !is_readable($path)) { return ['deleted' => [], 'failed' => []]; } // Only the header line is needed; a robots.txt large enough for the rest // to matter is still ours or still not. // 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. $head = (string) file_get_contents($path, false, null, 0, 128); if (strpos($head, THINKRANK_ROBOTS_HEADER) !== 0) { // Someone else's file. Leave it exactly as it is. return ['deleted' => [], 'failed' => []]; } wp_delete_file($path); return file_exists($path) ? ['deleted' => [], 'failed' => ['robots.txt']] : ['deleted' => ['robots.txt'], 'failed' => []]; } } if (!function_exists('thinkrank_webroot_remove_htaccess_block')) { /** * Strip a `# BEGIN … # END ` block from `ABSPATH/.htaccess`. * * Strips the block outright rather than calling `insert_with_markers()` with * an empty insertion — that leaves the BEGIN/END markers behind as litter. * * @since 2.1.0 * * @param string $marker The marker name, without the BEGIN/END words. * @return bool True when a block was found and removed. */ function thinkrank_webroot_remove_htaccess_block(string $marker): bool { $htaccess = ABSPATH . '.htaccess'; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- A writability probe, not a write; WP_Filesystem would need credentials on some hosts. if (!file_exists($htaccess) || !is_readable($htaccess) || !is_writable($htaccess)) { return false; } // 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. $contents = file_get_contents($htaccess); if (!is_string($contents) || strpos($contents, '# BEGIN ' . $marker) === false) { return false; } $quoted = preg_quote($marker, '/'); $cleaned = preg_replace( '/\R*# BEGIN ' . $quoted . '.*?# END ' . $quoted . '[ \t]*\R?/s', '', $contents ); if (!is_string($cleaned)) { return false; } // A file left holding nothing but our (now removed) block was ours to // begin with — a pre-existing .htaccess would still have content. if (trim($cleaned) === '') { wp_delete_file($htaccess); return !file_exists($htaccess); } // Keep the file newline-terminated after the block is cut out. // 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. return false !== file_put_contents($htaccess, rtrim($cleaned, "\r\n") . "\n"); } } if (!function_exists('thinkrank_webroot_delete_llms_txt')) { /** * Remove `ABSPATH/llms.txt` and its `.htaccess` charset block. * * Ownership is recorded rather than sniffed: the file is only removed when * `thinkrank_llms_txt_published_at` says we published one. llms.txt has no * generated header to test — the document is entirely the user's prose — so * a site that hand-wrote its own llms.txt before installing ThinkRank keeps * it. * * The stored document (`thinkrank_llms_txt_content`) is left in place so a * reactivation can republish it. Uninstall's own option sweep removes it * afterwards when the user asked for their data to go. * * @since 2.1.0 * * @return array{deleted: string[], failed: string[]} */ function thinkrank_webroot_delete_llms_txt(): array { $deleted = []; $failed = []; if (get_option('thinkrank_llms_txt_published_at', null) === null) { // We have no record of publishing it — so it is not ours to remove. return ['deleted' => $deleted, 'failed' => $failed]; } $path = ABSPATH . 'llms.txt'; if (file_exists($path)) { wp_delete_file($path); if (file_exists($path)) { $failed[] = 'llms.txt'; } else { $deleted[] = 'llms.txt'; } } // The charset block only exists on Apache/LiteSpeed, and only alongside // a static publish — but strip it whenever it is there, since it names // a file that no longer is. if (thinkrank_webroot_remove_htaccess_block(THINKRANK_LLMS_HTACCESS_MARKER)) { $deleted[] = '.htaccess (llms.txt charset block)'; } return ['deleted' => $deleted, 'failed' => $failed]; } } if (!function_exists('thinkrank_webroot_delete_indexnow_key')) { /** * Remove the IndexNow key file the activator drops in the web root. * * `Activator::setup_indexnow_key()` writes `ABSPATH/<32-hex-key>.txt`, whose * name is the key itself. Only the exact filename recorded in the settings * is removed — never a `*.txt` sweep — and only when it looks like a key we * generated. * * Uninstall only, deliberately. The activator recreates this file solely * when `api_key` is empty, so a deactivation that removed it would leave * IndexNow silently broken after the plugin came back — and unlike the * sitemap, a stray key file shadows nothing. * * @since 2.1.0 * * @return array{deleted: string[], failed: string[]} */ function thinkrank_webroot_delete_indexnow_key(): array { $settings = get_option('thinkrank_instant_indexing_settings', []); $key = is_array($settings) ? (string) ($settings['api_key'] ?? '') : ''; // The generated shape is bin2hex(random_bytes(16)). Anything else is not // ours to delete, and this keeps a tampered option from naming an // arbitrary file in the web root. if (!preg_match('/^[a-f0-9]{32}$/i', $key)) { return ['deleted' => [], 'failed' => []]; } $name = $key . '.txt'; $path = ABSPATH . $name; if (!file_exists($path)) { return ['deleted' => [], 'failed' => []]; } wp_delete_file($path); return file_exists($path) ? ['deleted' => [], 'failed' => [$name]] : ['deleted' => [$name], 'failed' => []]; } } if (!function_exists('thinkrank_webroot_cleanup')) { /** * Remove every artifact ThinkRank published into the web root. * * Called from both removal paths — deactivation and uninstall — because a * file that shadows the next plugin's routes does so whether ThinkRank was * switched off or deleted outright. Deactivation pairs this with * {@see \ThinkRank\Core\Activator::restore_webroot_artifacts()}, which puts * the files back when the plugin is switched on again. * * Ordering note for uninstall: this has to run *before* the options and * tables are dropped. Every ownership test below reads state that the * database cleanup is about to remove — the sitemap filenames come from the * settings table, and llms.txt ownership from an option. * * @since 2.1.0 * * @param array|null $sitemap_settings Optional. Already-read sitemap * settings; read from the database when * omitted. * @return array{deleted: string[], failed: string[]} Everything removed, and * everything that was * there but would not go. */ function thinkrank_webroot_cleanup(?array $sitemap_settings = null): array { $settings = $sitemap_settings ?? thinkrank_webroot_read_sitemap_settings(); $results = [ thinkrank_webroot_delete_sitemaps($settings), thinkrank_webroot_delete_robots_txt(), thinkrank_webroot_delete_llms_txt(), thinkrank_webroot_delete_indexnow_key(), ]; $deleted = []; $failed = []; foreach ($results as $result) { $deleted = array_merge($deleted, $result['deleted']); $failed = array_merge($failed, $result['failed']); } return [ 'deleted' => array_values(array_unique($deleted)), 'failed' => array_values(array_unique($failed)), ]; } }