prefix . 'storesuit_redirects'; } public static function misses() : string { global $wpdb; return $wpdb->prefix . 'storesuit_404'; } public static function install() : void { if ( get_option( self::SCHEMA ) === '1' ) { return; } global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset = $wpdb->get_charset_collate(); dbDelta( 'CREATE TABLE ' . self::table() . " (\n id bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n source_hash char(40) NOT NULL,\n source varchar(255) NOT NULL,\n prefix tinyint(1) NOT NULL DEFAULT 0,\n target text NOT NULL,\n code smallint(3) NOT NULL DEFAULT 301,\n hits bigint(20) unsigned NOT NULL DEFAULT 0,\n last_hit datetime NULL,\n auto tinyint(1) NOT NULL DEFAULT 0,\n created_at datetime NOT NULL,\n PRIMARY KEY (id),\n UNIQUE KEY source_hash (source_hash)\n ) {$charset};" ); dbDelta( 'CREATE TABLE ' . self::misses() . " (\n path_hash char(40) NOT NULL,\n path varchar(255) NOT NULL,\n hits bigint(20) unsigned NOT NULL DEFAULT 0,\n bot_hits bigint(20) unsigned NOT NULL DEFAULT 0,\n referrer varchar(190) NOT NULL DEFAULT '',\n first_seen datetime NOT NULL,\n last_seen datetime NOT NULL,\n PRIMARY KEY (path_hash),\n KEY last_seen (last_seen)\n ) {$charset};" ); $has = static fn( string $t ) => $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $t ) ) ) === $t; // Checked on every page that WordPress does not find, so it is loaded with the other options. if ( $has( self::table() ) && $has( self::misses() ) ) { update_option( self::SCHEMA, '1', true ); } } public static function hooks() : void { if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( '\\WP_CLI' ) ) { \WP_CLI::add_command( 'storeruler redirects import', array(__CLASS__, 'cli_import'), array( 'shortdesc' => 'Import redirects from a CSV file (source, target, code), as the Redirects screen does.', 'synopsis' => array(array( 'type' => 'positional', 'name' => 'file', 'description' => 'The CSV file.', 'optional' => false, ), array( 'type' => 'flag', 'name' => 'dry-run', 'description' => 'Check every line and save nothing.', 'optional' => true, )), ) ); } if ( get_option( self::SCHEMA ) !== '1' ) { return; } add_action( 'template_redirect', array(__CLASS__, 'handle'), 1 ); add_action( 'template_redirect', array(__CLASS__, 'missed'), 999 ); add_action( 'edit_terms', array(__CLASS__, 'term_before'), 10, 2 ); add_action( 'edited_term', array(__CLASS__, 'term_after'), 10, 3 ); add_action( self::CLEAN, array(__CLASS__, 'clean') ); add_action( 'init', static function () { if ( !wp_next_scheduled( self::CLEAN ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CLEAN ); } }, 99 ); add_action( 'switch_theme', array(__CLASS__, 'theme_switched'), 10, 3 ); } public const PREVIOUS_ROUTES = 'storesuit_previous_theme_routes'; /** * A theme switch (WordPress's own switch_theme, in the admin request that switches): the previous theme's address rules, * still the saved ones at this moment, are kept, so Health can tell which kinds of address only that theme served * (owner, 27 Oct 2026: a store lost a third of its search visits to addresses its old theme had added). Rules only. */ public static function theme_switched( $new_name = '', $new_theme = null, $old_theme = null ) : void { $rules = get_option( 'rewrite_rules' ); if ( !is_array( $rules ) || !$rules ) { return; } update_option( self::PREVIOUS_ROUTES, array( 'theme' => ( $old_theme instanceof \WP_Theme ? (string) $old_theme->get( 'Name' ) : '' ), 'time' => time(), 'rules' => array_slice( array_keys( $rules ), 0, 1500 ), ), false ); } /** * Kinds of address that now return "not found" often (the miss report grouped by its first path segment), marked when * the previous theme's rules matched them and no rule matches them now. Admin side only: reads the report, never a page. * @return array */ public static function lost_routes( int $limit = 5 ) : array { global $wpdb; if ( get_option( self::SCHEMA ) !== '1' ) { return array(); } $rows = ( $wpdb->get_results( $wpdb->prepare( 'SELECT path, hits FROM %i WHERE hits>0 ORDER BY hits DESC LIMIT 500', self::misses() ), ARRAY_A ) ?: array() ); $snapshot = get_option( self::PREVIOUS_ROUTES ); $now = array_keys( (array) get_option( 'rewrite_rules', array() ) ); $lost = ( is_array( $snapshot ) ? array_values( array_diff( (array) ($snapshot['rules'] ?? array()), $now ) ) : array() ); $base = rtrim( (string) wp_parse_url( home_url(), PHP_URL_PATH ), '/' ); $groups = array(); foreach ( $rows as $row ) { $path = (string) $row['path']; $relative = ( $base !== '' && str_starts_with( $path, $base . '/' ) ? substr( $path, strlen( $base ) ) : $path ); $segment = strtok( ltrim( $relative, '/' ), '/' ); if ( $segment === false || $segment === '' || preg_match( '/\\.(php|xml|txt|js|css|map|png|jpe?g|gif|webp|svg|ico)$/i', $relative ) ) { continue; } // files and probes are not pages $prefix = '/' . $segment . '/'; $groups[$prefix] = $groups[$prefix] ?? array( 'prefix' => $prefix, 'hits' => 0, 'paths' => 0, 'example' => $path, 'previous' => false, ); $groups[$prefix]['hits'] += (int) $row['hits']; $groups[$prefix]['paths']++; if ( !$groups[$prefix]['previous'] ) { foreach ( $lost as $regex ) { if ( @preg_match( '#^' . str_replace( '#', '\\#', (string) $regex ) . '#', ltrim( $relative, '/' ) ) ) { $groups[$prefix]['previous'] = true; break; } } } // phpcs:ignore WordPress.PHP.NoSilencedErrors -- a stored rule that is not a valid pattern is skipped. } // Worth a look: an address kind the previous theme served, or one that misses often across several addresses. $groups = array_values( array_filter( $groups, static fn( array $g ): bool => $g['previous'] && $g['hits'] >= 3 || $g['paths'] >= 3 && $g['hits'] >= 20 ) ); usort( $groups, static fn( array $a, array $b ): int => array($b['previous'], $b['hits']) <=> array($a['previous'], $a['hits']) ); return array_slice( $groups, 0, $limit ); } public static function deactivate() : void { wp_clear_scheduled_hook( self::CLEAN ); } /** An address as it is kept: its path only (no domain, no query), decoded, lower case, without a trailing slash. */ public static function normalize( string $url ) : string { $path = (string) wp_parse_url( trim( $url ), PHP_URL_PATH ); $path = '/' . ltrim( (string) preg_replace( '/[\\x00-\\x1F\\x7F]/', '', wp_check_invalid_utf8( rawurldecode( $path ), true ) ), '/' ); $path = ( $path === '/' ? '/' : rtrim( $path, '/' ) ); return mb_substr( mb_strtolower( $path ), 0, 255 ); } private static function hash( string $path, bool $prefix ) : string { return sha1( (( $prefix ? 'p:' : 'e:' )) . $path ); } /** Addresses a redirect never takes: the admin, the login, the REST API and WordPress's own files (also under a subdirectory install). */ private static function reserved( string $path ) : bool { $base = rtrim( (string) wp_parse_url( home_url(), PHP_URL_PATH ), '/' ); if ( $base !== '' && str_starts_with( $path, mb_strtolower( $base ) . '/' ) ) { $path = substr( $path, strlen( $base ) ); } return (bool) preg_match( '#^/(wp-admin|wp-login\\.php|wp-json|wp-includes|wp-content|xmlrpc\\.php|wp-cron\\.php)(/|$)#', $path ); } /** The site's scheme and host: local paths are kept from the domain root, like the addresses asked for (review 0.60 M2). */ private static function origin() : string { return (string) preg_replace( '#^(https?://[^/]+).*$#i', '$1', home_url() ); } /** * The redirect for an address: its exact rule, else the rule for the longest address it sits under. A page of results * (/page/2) or a feed is never taken by a rule for everything under an address (a live store, 28 Oct 2026): only an * exact rule moves it, and WordPress answers the rest itself. */ public static function match( string $path ) : ?array { global $wpdb; $hashes = array(self::hash( $path, false )); $at = $path; while ( !preg_match( '#/(page/\\d+|feed(/(rss|rss2|atom|rdf))?)$#', $path ) ) { $hashes[] = self::hash( $at, true ); if ( $at === '/' ) { break; } $cut = strrpos( $at, '/' ); $at = ( $cut ? substr( $at, 0, $cut ) : '/' ); } $rule = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE source_hash IN (' . implode( ',', array_fill( 0, count( $hashes ), '%s' ) ) . ') ORDER BY prefix ASC, CHAR_LENGTH(source) DESC LIMIT 1', array_merge( array(self::table()), $hashes ) ), ARRAY_A ); return ( $rule ?: null ); } /** Where a redirect sends an address: a target ending in * gets the rest of the address under the rule's own. */ public static function destination( array $rule, string $path, string $query = '' ) : string { $target = (string) $rule['target']; if ( (int) $rule['prefix'] === 1 && str_ends_with( $target, '*' ) ) { $target = substr( $target, 0, -1 ) . ltrim( (string) substr( $path, strlen( rtrim( (string) $rule['source'], '/' ) ) ), '/' ); } $target = ( str_starts_with( $target, '/' ) ? self::origin() . $target : $target ); return ( $query !== '' && !str_contains( $target, '?' ) ? $target . '?' . $query : $target ); } /** On a page WordPress did not find: the owner's redirect, before WordPress guesses one. */ public static function handle() : void { if ( self::paused_by() !== '' ) { return; } if ( !is_404() || is_admin() || wp_doing_ajax() || defined( 'REST_REQUEST' ) && REST_REQUEST ) { return; } global $wpdb; $uri = ( isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '' ); // The address asked for, as a URL. $path = self::normalize( $uri ); if ( self::reserved( $path ) || !($rule = self::match( $path )) ) { return; } // Owner decision (21 Oct 2026): a target that is itself redirected (the owner's /old-campaign to a category renamed // since) is followed here to the end, so the visitor takes one hop with the first rule's code; the rules stay as the // owner wrote them. Every rule on the way counts as used, so none looks idle and is deleted while another leads to it. $end = ( (int) $rule['code'] === 410 ? array( 'code' => 410, 'ids' => array(), ) : self::follow( $path, self::destination( $rule, $path, (string) wp_parse_url( $uri, PHP_URL_QUERY ) ), (int) $rule['code'] ) ); $ids = array_merge( array((int) $rule['id']), $end['ids'] ?? array() ); $wpdb->query( $wpdb->prepare( 'UPDATE %i SET hits=hits+1, last_hit=%s WHERE id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( array(self::table(), current_time( 'mysql', true )), $ids ) ) ); if ( !$end ) { return; } // Rules that send visitors round in a circle (or on for more than five rules) show the page as not found instead (review 0.60 M4). if ( $end['code'] === 410 ) { // Gone means gone: WordPress's own guesses and old-slug redirects do not answer instead, and the report does not // count it again (review 0.60 M3). remove_action( 'template_redirect', 'redirect_canonical' ); remove_action( 'template_redirect', 'wp_old_slug_redirect' ); remove_action( 'template_redirect', array(__CLASS__, 'missed'), 999 ); status_header( 410 ); nocache_headers(); return; } $to = $end['to']; $last = $end['rule'] ?? $rule; // The owner's own list (set by a store manager) may send an old address to another site, a store that moved: the host // of the last rule's own target (this site's for a path) is allowed for this redirect only, in the form WordPress checks // and sends it, with UTF-8 such as an internationalized domain name percent-encoded. A host the visitor's part of the // address would make up is not the rule's, so it is refused. A refused address is not followed: the visitor sees the // page as not found, never wp_safe_redirect()'s fallback, the admin (review of wporg-v1, M1). $own = ( str_starts_with( (string) $last['target'], '/' ) ? self::origin() : (string) $last['target'] ); $host = (string) wp_parse_url( wp_sanitize_redirect( $own ), PHP_URL_HOST ); $allow = static fn( $hosts ) => ( $host !== '' ? array_merge( (array) $hosts, array($host) ) : (array) $hosts ); add_filter( 'allowed_redirect_hosts', $allow ); try { $safe = wp_validate_redirect( $to, '' ); if ( $safe === '' ) { return; } wp_safe_redirect( $safe, ( in_array( $end['code'], array(302, 307, 308), true ) ? $end['code'] : 301 ), 'StoreRuler' ); } finally { remove_filter( 'allowed_redirect_hosts', $allow ); } exit; } /** * Follows the list from a rule's destination: while it is an address on this site that another rule answers, it goes on * to where that one sends it (the query string carried as a second hop would carry it), up to five rules in all. Returns * where it ends with the first rule's code (410 when a rule on the way is gone), the rules followed after the first and * the last of them; null when it comes back to an address already passed or goes on longer. One lookup per rule followed. */ private static function follow( string $from, string $to, int $code ) : ?array { $origin = self::origin(); $seen = array( $from => true, ); $ids = array(); $last = null; for ($i = 0; $i < 5; $i++) { if ( stripos( $to, $origin . '/' ) !== 0 ) { break; } // Another site's address ends it. $path = self::normalize( $to ); if ( isset( $seen[$path] ) ) { return null; } $seen[$path] = true; if ( !($next = self::match( $path )) ) { break; } $ids[] = (int) $next['id']; $last = $next; if ( (int) $next['code'] === 410 ) { return array( 'to' => '', 'code' => 410, 'ids' => $ids, 'rule' => $last, ); } $to = self::destination( $next, $path, (string) wp_parse_url( $to, PHP_URL_QUERY ) ); } return ( $i === 5 ? null : array( 'to' => $to, 'code' => $code, 'ids' => $ids, 'rule' => $last, ) ); } /** Whether following the list from this address comes back to it (or goes on for more than five steps on this site). */ public static function loops( string $from, string $to ) : bool { return self::follow( $from, $to, 301 ) === null; } /** Still not found after WordPress's own redirects (renamed products, guessed addresses): counted in the report. */ public static function missed() : void { if ( self::paused_by() !== '' ) { return; } if ( !is_404() || is_admin() || wp_doing_ajax() ) { return; } $path = self::normalize( ( isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '' ) ); // The browser's name is only read for a few words (people or search engines), never kept. $agent = ( isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '' ); if ( !self::reserved( $path ) ) { self::record( $path, $agent, esc_url_raw( (string) wp_get_raw_referer() ) ); } } /** Counts a not-found address: people and search engines apart, the referring site's name only, probes left out. */ public static function record( string $path, string $agent, string $referrer ) : void { global $wpdb; if ( $path === '/' || preg_match( '#(\\.(php\\d?|env|git|sql|bak|old|zip|gz|tar|ini|log|asp|aspx|jsp|cgi|yml|yaml)(/|$)|/\\.)#i', $path ) ) { return; } $bot = trim( $agent ) === '' || (bool) preg_match( '/bot|crawl|spider|slurp|facebookexternalhit|preview|curl|wget|python|java\\/|go-http|scan|monitor|headless/i', $agent ); $hash = sha1( $path ); $now = current_time( 'mysql', true ); // Security review S18 and its review: one visitor network (an IPv6 /64) adds at most 60 new addresses an hour and // counts once a day per address, so it cannot raise an entry faster than one hit a day, however often it asks. A full // report always takes a new address by dropping its weakest entry (fewest hits, then longest unseen): a real broken // link that more visitors reach outranks anything one network keeps asking for. The counters hold keyed hashes only. $known = (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM %i WHERE path_hash=%s', self::misses(), $hash ) ); $network = Store::visitor_network(); if ( $network !== '' ) { if ( !$known && !Store::throttle( 'not-found-network', $network, 60, HOUR_IN_SECONDS ) ) { return; } if ( !Store::throttle( 'not-found-hit', $network . '|' . $hash, 1, DAY_IN_SECONDS ) ) { return; } } if ( !$known && (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', self::misses() ) ) >= self::MAX_MISSES ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i ORDER BY hits+bot_hits ASC, last_seen ASC LIMIT 1', self::misses() ) ); } $from = mb_substr( (string) preg_replace( '/[^a-z0-9.\\-:\\[\\]]/', '', strtolower( (string) wp_parse_url( $referrer, PHP_URL_HOST ) ) ), 0, 190 ); // The referring site's name only. $wpdb->query( $wpdb->prepare( "INSERT INTO %i (path_hash, path, hits, bot_hits, referrer, first_seen, last_seen) VALUES (%s, %s, %d, %d, %s, %s, %s) ON DUPLICATE KEY UPDATE hits=hits+VALUES(hits), bot_hits=bot_hits+VALUES(bot_hits), last_seen=VALUES(last_seen), referrer=IF(VALUES(referrer)<>'', VALUES(referrer), referrer)", self::misses(), $hash, $path, ( $bot ? 0 : 1 ), ( $bot ? 1 : 0 ), $from, $now, $now ) ); do_action( 'storesuit_404_recorded', $path, $bot ); // A neutral hook: the address counted and whether it was a robot. Nothing personal. } public static function clean() : void { global $wpdb; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE last_seen < %s', self::misses(), gmdate( 'Y-m-d H:i:s', time() - self::KEEP_DAYS * DAY_IN_SECONDS ) ) ); } // ---- The team's screen ------------------------------------------------------------------------------------------- /** A target: a path on this site, or a full http(s) address. '' when it is neither. */ private static function target( string $raw ) : string { $raw = trim( $raw ); if ( str_starts_with( $raw, '/' ) && !str_starts_with( $raw, '//' ) ) { return mb_substr( esc_url_raw( $raw ), 0, 2000 ); } return ( preg_match( '#^https?://[^/\\s]+#i', $raw ) ? mb_substr( esc_url_raw( $raw, array('http', 'https') ), 0, 2000 ) : '' ); } /** Checks one redirect and returns it as it will be kept, or throws with the reason. */ private static function checked( string $source, string $target, int $code, bool $prefix ) : array { global $wpdb; $source = trim( $source ); if ( str_ends_with( $source, '*' ) ) { $prefix = true; $source = substr( $source, 0, -1 ); } $path = ( $source === '' ? '' : self::normalize( $source ) ); if ( $path === '' || $path === '/' && !$prefix ) { throw new \InvalidArgumentException(I18n::t( 'Enter the old address, for example /old-product.' )); } if ( self::reserved( $path ) ) { throw new \InvalidArgumentException(I18n::t( 'Addresses of the WordPress admin, the login and the REST API cannot be redirected.' )); } if ( !in_array( $code, array(301, 302, 410), true ) ) { throw new \InvalidArgumentException(I18n::t( 'Choose 301, 302 or 410.' )); } $to = ( $code === 410 ? '' : self::target( ( $prefix ? $target : rtrim( trim( $target ), '*' ) ) ) ); if ( $code !== 410 && $to === '' ) { throw new \InvalidArgumentException(I18n::t( 'Enter where it goes: a path such as /new-product or a full address.' )); } // A * only ends a path: right after a host it would let a visitor choose the host (review 0.60 L2). if ( str_contains( substr( $to, 0, -1 ), '*' ) || str_ends_with( $to, '*' ) && !str_starts_with( $to, '/' ) && !preg_match( '#^https?://[^/]+/#i', $to ) ) { throw new \InvalidArgumentException(I18n::t( 'A * can only end the new address, after a /.' )); } if ( $prefix && $to !== '' && (str_starts_with( $to, '/' ) || stripos( $to, self::origin() . '/' ) === 0) ) { $inner = self::normalize( rtrim( $to, '*' ) ); if ( $inner === $path || $path !== '/' && str_starts_with( $inner, rtrim( $path, '/' ) . '/' ) ) { throw new \InvalidArgumentException(I18n::t( 'This would send visitors round in a circle. Check where the new address goes.' )); } } if ( $to !== '' && !str_contains( $to, '*' ) ) { $local = str_starts_with( $to, '/' ) || wp_parse_url( $to, PHP_URL_HOST ) === wp_parse_url( home_url(), PHP_URL_HOST ); $back = ( $local ? $wpdb->get_var( $wpdb->prepare( 'SELECT target FROM %i WHERE source_hash=%s', self::table(), self::hash( self::normalize( $to ), false ) ) ) : null ); if ( $local && (self::normalize( $to ) === $path || is_string( $back ) && $back !== '' && self::normalize( $back ) === $path) ) { throw new \InvalidArgumentException(I18n::t( 'This would send visitors round in a circle. Check where the new address goes.' )); } } return array( 'source' => $path, 'prefix' => ( $prefix ? 1 : 0 ), 'target' => $to, 'code' => $code, ); } /** Saves a redirect; the addresses it now answers leave the not-found report. */ private static function upsert( array $rule, int $auto = 0 ) : void { global $wpdb; $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (source_hash, source, prefix, target, code, auto, created_at) VALUES (%s, %s, %d, %s, %d, %d, %s) ON DUPLICATE KEY UPDATE target=VALUES(target), code=VALUES(code), auto=VALUES(auto)', self::table(), self::hash( $rule['source'], (bool) $rule['prefix'] ), $rule['source'], $rule['prefix'], $rule['target'], $rule['code'], $auto, current_time( 'mysql', true ) ) ); if ( (int) $rule['prefix'] === 1 ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE path=%s OR path LIKE %s', self::misses(), $rule['source'], (( $rule['source'] === '/' ? '' : $wpdb->esc_like( $rule['source'] ) )) . '/%' ) ); } else { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE path_hash=%s', self::misses(), sha1( $rule['source'] ) ) ); } } public static function read( array $data = array() ) : array { Store::require_cap( 'manage_woocommerce' ); global $wpdb; $table = self::table(); // The statements are literal: the search, the type, "never used" and the order are constant switches (%d=0 OR ...), // every value a placeholder. Almira audit MPS-29: over 500 rules, the busy, unused and gone ones were hard to find. $search = trim( Store::text( $data, 'search' ) ); $has = ( $search !== '' ? 1 : 0 ); $like_source = '%' . $wpdb->esc_like( mb_strtolower( $search ) ) . '%'; $like_target = '%' . $wpdb->esc_like( $search ) . '%'; $show = Store::text( $data, 'show' ); $show = ( in_array( $show, array( '301', '302', '410', 'unused' ), true ) ? $show : '' ); $code = ( ctype_digit( $show ) ? (int) $show : 0 ); $unused = ( $show === 'unused' ? 1 : 0 ); $order = ( array_search( Store::text( $data, 'order' ), array( 1 => 'used', 2 => 'last', ), true ) ?: 0 ); $count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE (%d=0 OR (source LIKE %s OR target LIKE %s)) AND (%d=0 OR code=%d) AND (%d=0 OR hits=0)', $table, $has, $like_source, $like_target, $code, $code, $unused ) ); $pages = max( 1, (int) ceil( $count / 50 ) ); $page = min( max( 1, absint( $data['page'] ?? 1 ) ), $pages ); $rows = ( $wpdb->get_results( $wpdb->prepare( 'SELECT id, source, prefix, target, code, hits, last_hit, auto FROM %i WHERE (%d=0 OR (source LIKE %s OR target LIKE %s)) AND (%d=0 OR code=%d) AND (%d=0 OR hits=0) ORDER BY CASE WHEN %d=1 THEN hits END DESC, CASE WHEN %d=2 THEN last_hit END DESC, id DESC LIMIT 50 OFFSET %d', $table, $has, $like_source, $like_target, $code, $code, $unused, $order, $order, ($page - 1) * 50 ), ARRAY_A ) ?: array() ); $totals = ( $wpdb->get_row( $wpdb->prepare( 'SELECT COUNT(*) AS rules, SUM(last_hit>=%s) AS used FROM %i', gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS ), $table ), ARRAY_A ) ?: array() ); // Where a rule whose target is itself redirected now ends (as handle() sends visitors), shown under its target so no // one has to act; null when it goes where it says. A target ending in * depends on the address asked for: not shown. $origin = self::origin(); $chain = static function ( array $r ) use($origin) : ?array { if ( (int) $r['code'] === 410 || str_contains( (string) $r['target'], '*' ) ) { return null; } $end = self::follow( (string) $r['source'], self::destination( $r, (string) $r['source'] ), (int) $r['code'] ); if ( !$end ) { return array( 'to' => '', 'steps' => 0, 'gone' => false, 'loop' => true, ); } if ( !$end['ids'] ) { return null; } return array( 'to' => ( stripos( $end['to'], $origin . '/' ) === 0 ? substr( $end['to'], strlen( $origin ) ) : $end['to'] ), 'steps' => count( $end['ids'] ) + 1, 'gone' => $end['code'] === 410, 'loop' => false, ); }; return array( 'rows' => array_map( static fn( $r ) => array( 'id' => (int) $r['id'], 'source' => $r['source'], 'prefix' => (int) $r['prefix'] === 1, 'target' => $r['target'], 'code' => (int) $r['code'], 'hits' => (int) $r['hits'], 'last_hit' => ( $r['last_hit'] ? gmdate( 'c', (int) strtotime( $r['last_hit'] . ' UTC' ) ) : '' ), 'auto' => (int) $r['auto'] === 1, 'chain' => $chain( $r ), ), $rows ), 'count' => $count, 'page' => $page, 'pages' => $pages, 'other' => self::other_manager(), 'paused' => self::paused_by() !== '', 'home' => home_url( '/' ), 'show' => $show, 'order' => array( 0 => 'new', 1 => 'used', 2 => 'last', )[$order], 'rules' => (int) ($totals['rules'] ?? 0), 'used_30d' => (int) ($totals['used'] ?? 0), ); } public static function save( array $data ) : array { Store::require_cap( 'manage_woocommerce' ); global $wpdb; $raw = static fn( string $key ) => ( is_scalar( $data[$key] ?? null ) ? (string) $data[$key] : '' ); // Not sanitize_text_field: it drops %-encoded letters of an address. $rule = self::checked( $raw( 'source' ), $raw( 'target' ), (int) ($data['code'] ?? 301), !empty( $data['prefix'] ) ); $id = absint( $data['id'] ?? 0 ); if ( $id ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id=%d', self::table(), $id ) ); } elseif ( (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', self::table() ) ) >= self::MAX_RULES ) { throw new \InvalidArgumentException(I18n::t( 'A store can keep at most 20,000 redirects.' )); } self::upsert( $rule ); Store::audit( 'settings', 0, 'redirect_saved', array( 'source' => $rule['source'], 'code' => $rule['code'], ) ); return self::read(); } /** Whether a redirect set now is followed: the list is set up and no other redirect manager has taken over. */ public static function ready() : bool { return get_option( self::SCHEMA ) === '1' && self::paused_by() === ''; } /** * One redirect another screen sets for the owner (the SEO Center's orphan pages, 28 Oct 2026), checked and kept the way * a saved one is, with where it came from in the log. Returns the rule as kept. */ public static function add( string $source, string $target, int $code, string $via ) : array { Store::require_cap( 'manage_woocommerce' ); global $wpdb; if ( !self::ready() ) { throw new \RuntimeException(I18n::t( 'StoreRuler\'s redirects are not in use on this site, so no redirect was set.' )); } $rule = self::checked( $source, $target, $code, false ); $known = (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM %i WHERE source_hash=%s', self::table(), self::hash( $rule['source'], false ) ) ); if ( !$known && (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', self::table() ) ) >= self::MAX_RULES ) { throw new \InvalidArgumentException(I18n::t( 'A store can keep at most 20,000 redirects.' )); } self::upsert( $rule ); Store::audit( 'settings', 0, 'redirect_saved', array( 'source' => $rule['source'], 'code' => $rule['code'], 'via' => $via, ) ); return $rule; } public static function remove( array $data ) : array { Store::require_cap( 'manage_woocommerce' ); global $wpdb; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id=%d', self::table(), absint( $data['id'] ?? 0 ) ) ); return self::read(); } /** * A CSV list (source, target, code; a first row that is not an address is a header). With commit off every line is * checked and nothing is saved; with commit on the valid lines are saved (a source already listed is updated). */ public static function import( array $data ) : array { Store::require_cap( 'manage_woocommerce' ); return self::import_csv( ( is_string( $data['csv'] ?? null ) ? $data['csv'] : '' ), !empty( $data['commit'] ) ); } /** import()'s work, without the signed-in check: the Redirects screen and the WP-CLI command (the server's own shell) call it. */ private static function import_csv( string $csv, bool $commit, string $via = 'admin' ) : array { global $wpdb; $lines = preg_split( '/\\r\\n|\\r|\\n/', $csv ); if ( count( $lines ) > 5001 ) { throw new \InvalidArgumentException(I18n::t( 'Import at most 5,000 lines at once.' )); } $valid = array(); $errors = array(); foreach ( $lines as $n => $line ) { if ( trim( $line ) === '' ) { continue; } $cells = str_getcsv( $line, ( str_contains( $line, ';' ) && !str_contains( $line, ',' ) ? ';' : ',' ), '"', '' ); $source = trim( (string) ($cells[0] ?? '') ); if ( $n === 0 && !str_starts_with( $source, '/' ) && !preg_match( '#^https?://#i', $source ) ) { continue; } try { $rule = self::checked( $source, trim( (string) ($cells[1] ?? '') ), ( (int) trim( (string) ($cells[2] ?? '') ) ?: 301 ), false ); $valid[self::hash( $rule['source'], (bool) $rule['prefix'] )] = $rule; } catch ( \InvalidArgumentException $e ) { $errors[] = array( 'line' => $n + 1, 'text' => mb_substr( $line, 0, 120 ), 'reason' => $e->getMessage(), ); } } $saved = 0; if ( $commit && $valid ) { if ( (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', self::table() ) ) + count( $valid ) > self::MAX_RULES ) { throw new \InvalidArgumentException(I18n::t( 'A store can keep at most 20,000 redirects.' )); } foreach ( $valid as $rule ) { self::upsert( $rule ); } // Each line is saved on its own, so importing the file again finishes a stopped import. $saved = count( $valid ); Store::audit( 'settings', 0, 'redirects_imported', array( 'count' => $saved, 'via' => $via, ) ); } return array( 'valid' => count( $valid ), 'saved' => $saved, 'errors' => array_slice( $errors, 0, 50 ), 'error_count' => count( $errors ), 'sample' => array_slice( array_values( $valid ), 0, 5 ), ); } /** * wp storeruler redirects import [--dry-run]: the same CSV the Redirects screen imports (source, target, * code), from the server's shell, with no signed-in admin (a live store, 27 Oct 2026: a list of old theme addresses had * to go in through a browser session). --dry-run checks every line and saves nothing. */ public static function cli_import( array $args, array $assoc ) : void { $file = (string) ($args[0] ?? ''); if ( $file === '' || !is_file( $file ) || !is_readable( $file ) ) { \WP_CLI::error( 'Give the path of a readable CSV file: wp storeruler redirects import [--dry-run]' ); } if ( filesize( $file ) > 5 * MB_IN_BYTES ) { \WP_CLI::error( 'The file is larger than 5 MB; split it.' ); } if ( get_option( self::SCHEMA ) !== '1' ) { \WP_CLI::error( 'StoreRuler\'s redirect table is not set up on this site yet.' ); } $dry = !empty( $assoc['dry-run'] ); try { $r = self::import_csv( (string) file_get_contents( $file ), !$dry, 'wp-cli' ); } catch ( \InvalidArgumentException $e ) { \WP_CLI::error( $e->getMessage() ); return; } foreach ( $r['errors'] as $error ) { \WP_CLI::warning( sprintf( 'Line %d: %s (%s)', $error['line'], $error['reason'], $error['text'] ) ); } if ( $r['error_count'] > count( $r['errors'] ) ) { \WP_CLI::warning( sprintf( '%d more lines with errors are not listed.', $r['error_count'] - count( $r['errors'] ) ) ); } \WP_CLI::log( sprintf( '%d valid redirects, %d lines with errors.', $r['valid'], $r['error_count'] ) ); if ( $dry ) { \WP_CLI::success( 'Dry run: nothing was saved.' ); return; } \WP_CLI::success( sprintf( '%d redirects saved (a source already listed is updated).', $r['saved'] ) ); } /** The whole list as CSV (source, target, code), the way import() reads it; "everything under" is a source ending in *. */ public static function export() : array { Store::require_cap( 'manage_woocommerce' ); global $wpdb; $cell = static fn( string $v ) => ( preg_match( '/[",;\\r\\n]/', $v ) ? '"' . str_replace( '"', '""', $v ) . '"' : $v ); $out = array('source,target,code'); foreach ( ( $wpdb->get_results( $wpdb->prepare( 'SELECT source, prefix, target, code FROM %i ORDER BY id', self::table() ), ARRAY_A ) ?: array() ) as $r ) { $out[] = $cell( $r['source'] . (( (int) $r['prefix'] === 1 ? ( $r['source'] === '/' ? '*' : '/*' ) : '' )) ) . ',' . $cell( (string) $r['target'] ) . ',' . (int) $r['code']; } return array( 'csv' => implode( "\n", $out ) . "\n", 'name' => 'redirects-' . gmdate( 'Y-m-d' ) . '.csv', ); } /** The not-found report: the most asked first. */ public static function not_found( array $data = array() ) : array { Store::require_cap( 'manage_woocommerce' ); global $wpdb; $table = self::misses(); $count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); $pages = max( 1, (int) ceil( $count / 50 ) ); $page = min( max( 1, absint( $data['page'] ?? 1 ) ), $pages ); $rows = ( $wpdb->get_results( $wpdb->prepare( 'SELECT path, hits, bot_hits, referrer, last_seen FROM %i ORDER BY hits DESC, bot_hits DESC, last_seen DESC LIMIT 50 OFFSET %d', $table, ($page - 1) * 50 ), ARRAY_A ) ?: array() ); return array( 'rows' => array_map( static fn( $r ) => array( 'path' => $r['path'], 'hits' => (int) $r['hits'], 'bot_hits' => (int) $r['bot_hits'], 'referrer' => $r['referrer'], 'last_seen' => gmdate( 'c', (int) strtotime( $r['last_seen'] . ' UTC' ) ), ), $rows ), 'count' => $count, 'page' => $page, 'pages' => $pages, 'host' => (string) wp_parse_url( home_url(), PHP_URL_HOST ), 'other' => self::paused_by(), 'paused' => self::paused_by() !== '', ); } public static function clear_not_found() : array { Store::require_cap( 'manage_woocommerce' ); global $wpdb; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', self::misses() ) ); Store::audit( 'settings', 0, 'not_found_cleared' ); return self::not_found(); } /** * The other redirect manager StoreRuler steps aside for (Pro: SeoProviders::redirect_owner()): while one is found, handle() * serves none of this list and missed() counts no 404s. '' when there is none (always in the free build). */ public static function paused_by() : string { return ''; } /** * The other redirect tool named on the screen. Pro: the one paused_by() steps aside for (the screen says this list is * paused). Free: one that may answer first (Redirection, Yoast SEO Premium, Rank Math). */ private static function other_manager() : string { if ( defined( 'REDIRECTION_VERSION' ) ) { return 'Redirection'; } if ( defined( 'WPSEO_PREMIUM_VERSION' ) ) { return 'Yoast SEO Premium'; } if ( class_exists( 'RankMath\\Redirections\\Redirections' ) ) { return 'Rank Math'; } return ''; } // ---- Addresses that change ----------------------------------------------------------------------------------------- /** Before a category, tag or brand is saved: its address, and those of the categories under it. */ public static function term_before( $term_id, $taxonomy ) : void { if ( !in_array( $taxonomy, self::TAXONOMIES, true ) || get_option( self::SCHEMA ) !== '1' ) { return; } $ids = array((int) $term_id); if ( is_taxonomy_hierarchical( (string) $taxonomy ) ) { $ids = array_merge( $ids, array_slice( array_map( 'intval', (array) get_term_children( (int) $term_id, (string) $taxonomy ) ), 0, 500 ) ); } foreach ( $ids as $id ) { $link = get_term_link( $id, (string) $taxonomy ); if ( is_string( $link ) ) { self::$old_links[(string) $taxonomy][$id] = $link; } } } /** After: each address that changed redirects to the new one, unless the owner set that address by hand. */ public static function term_after( $term_id, $tt_id, $taxonomy ) : void { $old = self::$old_links[(string) $taxonomy] ?? array(); unset(self::$old_links[(string) $taxonomy]); if ( !$old ) { return; } global $wpdb; foreach ( $old as $id => $link ) { clean_term_cache( $id, (string) $taxonomy ); $new = get_term_link( $id, (string) $taxonomy ); if ( !is_string( $new ) ) { continue; } $from = self::normalize( $link ); $to = self::normalize( $new ); if ( $from === $to || $from === '/' || self::reserved( $from ) ) { continue; } // The new address is a page again: an automatic redirect away from it goes; older ones to the old address move on. $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE source_hash=%s AND auto=1', self::table(), self::hash( $to, false ) ) ); $wpdb->query( $wpdb->prepare( 'UPDATE %i SET target=%s WHERE auto=1 AND target=%s', self::table(), $to, $from ) ); if ( !$wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE source_hash=%s AND auto=0', self::table(), self::hash( $from, false ) ) ) ) { self::upsert( array( 'source' => $from, 'prefix' => 0, 'target' => $to, 'code' => 301, ), 1 ); } } } }