| 1 |
<?php |
| 2 |
|
| 3 |
/** |
| 4 |
* Import redirections from other SEO plugins. |
| 5 |
* |
| 6 |
* @link https://searchatlas.com |
| 7 |
* @since 1.0.0 |
| 8 |
* @package Metasync |
| 9 |
* @subpackage Metasync/redirections |
| 10 |
* @author Engineering Team <[email protected]> |
| 11 |
*/ |
| 12 |
|
| 13 |
# Abort if this file is accessed directly. |
| 14 |
if (!defined('ABSPATH')) { |
| 15 |
exit; |
| 16 |
} |
| 17 |
|
| 18 |
if (!class_exists('Metasync_Redirection_Validator')) { |
| 19 |
require_once dirname(__FILE__) . '/class-metasync-redirection-validator.php'; |
| 20 |
} |
| 21 |
|
| 22 |
class Metasync_Redirection_Importer |
| 23 |
{ |
| 24 |
/** |
| 25 |
* Maximum rows accepted from one CSV import. A malformed or oversized |
| 26 |
* export must not turn into an unbounded loop or a memory blow-up. |
| 27 |
*/ |
| 28 |
const CSV_MAX_ROWS = 20000; |
| 29 |
|
| 30 |
private $db_redirection; |
| 31 |
|
| 32 |
private $redirection_helper = null; |
| 33 |
|
| 34 |
/** |
| 35 |
* @var array<string, true>|null Memoized exact-key index of stored sources. |
| 36 |
*/ |
| 37 |
private $source_index_cache = null; |
| 38 |
|
| 39 |
/** |
| 40 |
* Supported plugins for import |
| 41 |
*/ |
| 42 |
const SUPPORTED_PLUGINS = [ |
| 43 |
'redirection' => [ |
| 44 |
'name' => 'Redirection', |
| 45 |
'slug' => 'redirection/redirection.php', |
| 46 |
'table' => 'redirection_items' |
| 47 |
], |
| 48 |
'yoast' => [ |
| 49 |
'name' => 'Yoast SEO Premium', |
| 50 |
'slugs' => [ |
| 51 |
'wordpress-seo-premium/wp-seo-premium.php', |
| 52 |
'wordpress-seo-premium-main/wp-seo-premium.php', |
| 53 |
'wordpress-seo/wp-seo.php' |
| 54 |
], |
| 55 |
'options' => [ |
| 56 |
'wpseo-premium-redirects-base', |
| 57 |
'wpseo-premium-redirects-export-plain', |
| 58 |
'wpseo-premium-redirects-export-regex', |
| 59 |
'wpseo-premium-redirects', |
| 60 |
'wpseo-premium-redirects-regex' |
| 61 |
] |
| 62 |
], |
| 63 |
'rankmath' => [ |
| 64 |
'name' => 'Rank Math', |
| 65 |
'slug' => 'seo-by-rank-math/rank-math.php', |
| 66 |
'table' => 'rank_math_redirections' |
| 67 |
], |
| 68 |
'aioseo' => [ |
| 69 |
'name' => 'All in One SEO', |
| 70 |
'slug' => 'all-in-one-seo-pack/all_in_one_seo_pack.php', |
| 71 |
'table' => 'aioseo_redirects' |
| 72 |
], |
| 73 |
'simple301' => [ |
| 74 |
'name' => 'Simple 301 Redirects', |
| 75 |
'slug' => 'simple-301-redirects/simple-301-redirects.php', |
| 76 |
'option' => '301_redirects' |
| 77 |
] |
| 78 |
]; |
| 79 |
|
| 80 |
public function __construct(&$db_redirection) |
| 81 |
{ |
| 82 |
$this->db_redirection = $db_redirection; |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* Lazy-load the Metasync_Redirection helper used for loop detection. |
| 87 |
*/ |
| 88 |
private function get_redirection_helper() |
| 89 |
{ |
| 90 |
if ($this->redirection_helper === null) { |
| 91 |
require_once dirname(__FILE__) . '/class-metasync-redirection.php'; |
| 92 |
$this->redirection_helper = new Metasync_Redirection($this->db_redirection); |
| 93 |
} |
| 94 |
return $this->redirection_helper; |
| 95 |
} |
| 96 |
|
| 97 |
/** |
| 98 |
* Get list of available plugins for import |
| 99 |
* |
| 100 |
* @return array List of plugins with their status |
| 101 |
*/ |
| 102 |
public function get_available_plugins() |
| 103 |
{ |
| 104 |
global $wpdb; |
| 105 |
$available = []; |
| 106 |
|
| 107 |
foreach (self::SUPPORTED_PLUGINS as $key => $plugin) { |
| 108 |
$status = [ |
| 109 |
'key' => $key, |
| 110 |
'name' => $plugin['name'], |
| 111 |
'installed' => false, |
| 112 |
'has_data' => false, |
| 113 |
'count' => 0 |
| 114 |
]; |
| 115 |
|
| 116 |
# Check if plugin is installed/active |
| 117 |
if (isset($plugin['slug'])) { |
| 118 |
$status['installed'] = is_plugin_active($plugin['slug']) || $this->plugin_exists($plugin['slug']); |
| 119 |
} elseif (isset($plugin['slugs'])) { |
| 120 |
# Check multiple possible slugs (for Yoast) |
| 121 |
foreach ($plugin['slugs'] as $slug) { |
| 122 |
if (is_plugin_active($slug) || $this->plugin_exists($slug)) { |
| 123 |
$status['installed'] = true; |
| 124 |
break; |
| 125 |
} |
| 126 |
} |
| 127 |
} |
| 128 |
|
| 129 |
# Check if data exists. Table names come only from the fixed |
| 130 |
# SUPPORTED_PLUGINS allowlist above ('redirection_items', |
| 131 |
# 'rank_math_redirections', 'aioseo_redirects'). |
| 132 |
if (isset($plugin['table']) && in_array($plugin['table'], ['redirection_items', 'rank_math_redirections', 'aioseo_redirects'], true)) { |
| 133 |
$table_name = $wpdb->prefix . $plugin['table']; |
| 134 |
if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table_name))) === $table_name) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists |
| 135 |
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name verified against the literal allowlist above |
| 136 |
$count = $wpdb->get_var('SELECT COUNT(*) FROM ' . $wpdb->prefix . $plugin['table']); |
| 137 |
if ($count > 0) { |
| 138 |
$status['has_data'] = true; |
| 139 |
$status['count'] = (int)$count; |
| 140 |
} |
| 141 |
} |
| 142 |
} elseif (isset($plugin['option'])) { |
| 143 |
$option_data = get_option($plugin['option'], []); |
| 144 |
if (!empty($option_data)) { |
| 145 |
$status['has_data'] = true; |
| 146 |
$status['count'] = is_array($option_data) ? count($option_data) : 0; |
| 147 |
} |
| 148 |
} elseif (isset($plugin['options'])) { |
| 149 |
# Check multiple options (for Yoast) |
| 150 |
$total_count = 0; |
| 151 |
foreach ($plugin['options'] as $option_name) { |
| 152 |
$option_data = get_option($option_name, []); |
| 153 |
if (!empty($option_data)) { |
| 154 |
$status['has_data'] = true; |
| 155 |
if (is_array($option_data)) { |
| 156 |
$total_count += count($option_data); |
| 157 |
} |
| 158 |
} |
| 159 |
} |
| 160 |
if ($total_count > 0) { |
| 161 |
$status['count'] = $total_count; |
| 162 |
} |
| 163 |
} |
| 164 |
|
| 165 |
$available[] = $status; |
| 166 |
} |
| 167 |
|
| 168 |
return $available; |
| 169 |
} |
| 170 |
|
| 171 |
/** |
| 172 |
* Check if plugin exists (even if not active) |
| 173 |
*/ |
| 174 |
private function plugin_exists($plugin_path) |
| 175 |
{ |
| 176 |
$plugin_file = WP_PLUGIN_DIR . '/' . $plugin_path; |
| 177 |
return file_exists($plugin_file); |
| 178 |
} |
| 179 |
|
| 180 |
/** |
| 181 |
* Import redirections from specified plugin |
| 182 |
* |
| 183 |
* @param string $plugin Plugin key |
| 184 |
* @return array Result with success status and message |
| 185 |
*/ |
| 186 |
public function import_from_plugin($plugin) |
| 187 |
{ |
| 188 |
if (!isset(self::SUPPORTED_PLUGINS[$plugin])) { |
| 189 |
return [ |
| 190 |
'success' => false, |
| 191 |
'message' => 'Unknown plugin specified.', |
| 192 |
'imported' => 0, |
| 193 |
'skipped' => 0 |
| 194 |
]; |
| 195 |
} |
| 196 |
|
| 197 |
$method = "import_from_{$plugin}"; |
| 198 |
if (method_exists($this, $method)) { |
| 199 |
return $this->$method(); |
| 200 |
} |
| 201 |
|
| 202 |
return [ |
| 203 |
'success' => false, |
| 204 |
'message' => 'Import method not implemented.', |
| 205 |
'imported' => 0, |
| 206 |
'skipped' => 0 |
| 207 |
]; |
| 208 |
} |
| 209 |
|
| 210 |
/** |
| 211 |
* Import from Redirection plugin |
| 212 |
*/ |
| 213 |
private function import_from_redirection() |
| 214 |
{ |
| 215 |
global $wpdb; |
| 216 |
$table_name = $wpdb->prefix . 'redirection_items'; |
| 217 |
|
| 218 |
if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table_name))) !== $table_name) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists |
| 219 |
return [ |
| 220 |
'success' => false, |
| 221 |
'message' => 'Redirection plugin table not found.', |
| 222 |
'imported' => 0, |
| 223 |
'skipped' => 0, |
| 224 |
'loop_skipped' => 0 |
| 225 |
]; |
| 226 |
} |
| 227 |
|
| 228 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists |
| 229 |
$redirects = $wpdb->get_results(" |
| 230 |
SELECT * FROM {$wpdb->prefix}redirection_items |
| 231 |
WHERE status = 'enabled' |
| 232 |
"); |
| 233 |
|
| 234 |
$imported = 0; |
| 235 |
$skipped = 0; |
| 236 |
$loop_skipped = 0; |
| 237 |
$parse_skipped = 0; |
| 238 |
$unsafe_skipped = 0; |
| 239 |
$conditional_skipped = 0; |
| 240 |
|
| 241 |
# Exact-key index of every source already stored. One SELECT plus one |
| 242 |
# unserialize pass replaces the per-row LIKE rescan (quadratic on big |
| 243 |
# tables) and stops substring false-positives (/a "matching" /about). |
| 244 |
$existing_sources = $this->get_existing_source_index(); |
| 245 |
|
| 246 |
foreach ($redirects as $redirect) { |
| 247 |
# Conditional redirects (query/referrer/agent/… matches) only fire |
| 248 |
# under conditions MetaSync cannot represent. Importing them as |
| 249 |
# unconditional redirects would silently change their semantics. |
| 250 |
if (isset($redirect->match_type) && $redirect->match_type !== '' && $redirect->match_type !== 'url') { |
| 251 |
$conditional_skipped++; |
| 252 |
continue; |
| 253 |
} |
| 254 |
|
| 255 |
$http_code = intval($redirect->action_code ?? 301); |
| 256 |
|
| 257 |
# action_data is a JSON envelope ('{"url":"…"}'), never a plain URL. |
| 258 |
# Storing it raw produced redirects to a literal '{"url":…}' string. |
| 259 |
$target_url = $this->extract_destination($redirect->action_data ?? ''); |
| 260 |
|
| 261 |
# For 410 and 451, target URL can be empty (they don't redirect) |
| 262 |
if ($target_url === null || ($target_url === '' && !in_array($http_code, [410, 451]))) { |
| 263 |
$parse_skipped++; |
| 264 |
continue; |
| 265 |
} |
| 266 |
|
| 267 |
# Check if already exists (exact source-key match) |
| 268 |
if (isset($existing_sources[$redirect->url])) { |
| 269 |
$skipped++; |
| 270 |
continue; |
| 271 |
} |
| 272 |
|
| 273 |
# Map Redirection plugin format to MetaSync format |
| 274 |
$pattern_type = 'exact'; |
| 275 |
$regex_pattern = null; |
| 276 |
|
| 277 |
# Check if it's a regex. Foreign patterns arrive unvalidated — |
| 278 |
# screen them with the same ReDoS guard the admin form applies. |
| 279 |
if (isset($redirect->regex) && $redirect->regex == 1) { |
| 280 |
if (!Metasync_Redirection_Validator::is_regex_safe((string) $redirect->url)) { |
| 281 |
$parse_skipped++; |
| 282 |
continue; |
| 283 |
} |
| 284 |
$pattern_type = 'regex'; |
| 285 |
$regex_pattern = $redirect->url; |
| 286 |
} |
| 287 |
|
| 288 |
$sources = [ |
| 289 |
$redirect->url => $pattern_type |
| 290 |
]; |
| 291 |
|
| 292 |
$args = [ |
| 293 |
'sources_from' => serialize($sources), |
| 294 |
'url_redirect_to' => $target_url, |
| 295 |
'http_code' => $http_code, |
| 296 |
'hits_count' => $redirect->hits ?? 0, |
| 297 |
'status' => 'active', |
| 298 |
'pattern_type' => $pattern_type, |
| 299 |
'regex_pattern' => $regex_pattern, |
| 300 |
'description' => 'Imported from Redirection plugin', |
| 301 |
'created_at' => current_time('mysql'), |
| 302 |
'updated_at' => current_time('mysql') |
| 303 |
]; |
| 304 |
|
| 305 |
if (!$this->is_safe_destination($target_url, $http_code)) { |
| 306 |
$unsafe_skipped++; |
| 307 |
continue; |
| 308 |
} |
| 309 |
$args['url_redirect_to'] = $target_url; |
| 310 |
|
| 311 |
if (!in_array($http_code, [410, 451]) && $this->get_redirection_helper()->validate_no_loop($redirect->url, $target_url) !== null) { |
| 312 |
$loop_skipped++; |
| 313 |
continue; |
| 314 |
} |
| 315 |
|
| 316 |
if ($this->db_redirection->add($args)) { |
| 317 |
$imported++; |
| 318 |
$existing_sources[$redirect->url] = true; |
| 319 |
$this->remember_imported_source($redirect->url); |
| 320 |
} |
| 321 |
} |
| 322 |
|
| 323 |
$message = "Successfully imported $imported redirections from Redirection plugin."; |
| 324 |
if ($skipped > 0) { |
| 325 |
$message .= " Skipped $skipped already-existing redirection(s)."; |
| 326 |
} |
| 327 |
if ($parse_skipped > 0) { |
| 328 |
$message .= " Skipped $parse_skipped redirection(s) with an unreadable destination."; |
| 329 |
} |
| 330 |
if ($unsafe_skipped > 0) { |
| 331 |
$message .= " Skipped $unsafe_skipped redirection(s) with an off-site destination."; |
| 332 |
} |
| 333 |
if ($conditional_skipped > 0) { |
| 334 |
$message .= " Skipped $conditional_skipped conditional redirection(s); conditions are not supported."; |
| 335 |
} |
| 336 |
if ($loop_skipped > 0) { |
| 337 |
$message .= " Skipped $loop_skipped redirect(s) that would have created loops."; |
| 338 |
} |
| 339 |
|
| 340 |
return [ |
| 341 |
'success' => true, |
| 342 |
'message' => $message, |
| 343 |
'imported' => $imported, |
| 344 |
'skipped' => $skipped, |
| 345 |
'loop_skipped' => $loop_skipped, |
| 346 |
'skipped_parse' => $parse_skipped, |
| 347 |
'skipped_unsafe' => $unsafe_skipped, |
| 348 |
'skipped_conditional' => $conditional_skipped |
| 349 |
]; |
| 350 |
} |
| 351 |
|
| 352 |
/** |
| 353 |
* Import from Yoast SEO Premium |
| 354 |
* |
| 355 |
* Yoast stores redirects in multiple options: |
| 356 |
* - wpseo-premium-redirects-base (main storage since v3.1) |
| 357 |
* - wpseo-premium-redirects-export-plain (plain redirects) |
| 358 |
* - wpseo-premium-redirects-export-regex (regex redirects) |
| 359 |
* - wpseo-premium-redirects (legacy plain) |
| 360 |
* - wpseo-premium-redirects-regex (legacy regex) |
| 361 |
*/ |
| 362 |
private function import_from_yoast() |
| 363 |
{ |
| 364 |
$imported = 0; |
| 365 |
$skipped = 0; |
| 366 |
$errors = []; |
| 367 |
$loop_skipped = 0; |
| 368 |
|
| 369 |
# Try to import from base option first (preferred method) |
| 370 |
$base_redirects = get_option('wpseo-premium-redirects-base', []); |
| 371 |
|
| 372 |
if (!empty($base_redirects) && is_array($base_redirects)) { |
| 373 |
foreach ($base_redirects as $redirect) { |
| 374 |
$result = $this->process_yoast_redirect_base($redirect); |
| 375 |
if ($result === true) { |
| 376 |
$imported++; |
| 377 |
} elseif ($result === 'skipped') { |
| 378 |
$skipped++; |
| 379 |
} elseif ($result === 'loop_skipped') { |
| 380 |
$loop_skipped++; |
| 381 |
} else { |
| 382 |
$errors[] = $result; |
| 383 |
} |
| 384 |
} |
| 385 |
} |
| 386 |
|
| 387 |
# If no base redirects, try export options |
| 388 |
if ($imported === 0 && $skipped === 0) { |
| 389 |
# Import plain redirects |
| 390 |
$plain_redirects = get_option('wpseo-premium-redirects-export-plain', []); |
| 391 |
if (!empty($plain_redirects) && is_array($plain_redirects)) { |
| 392 |
foreach ($plain_redirects as $origin => $redirect_data) { |
| 393 |
$result = $this->process_yoast_redirect_export($origin, $redirect_data, 'plain'); |
| 394 |
if ($result === true) { |
| 395 |
$imported++; |
| 396 |
} elseif ($result === 'skipped') { |
| 397 |
$skipped++; |
| 398 |
} elseif ($result === 'loop_skipped') { |
| 399 |
$loop_skipped++; |
| 400 |
} else { |
| 401 |
$errors[] = $result; |
| 402 |
} |
| 403 |
} |
| 404 |
} |
| 405 |
|
| 406 |
# Import regex redirects |
| 407 |
$regex_redirects = get_option('wpseo-premium-redirects-export-regex', []); |
| 408 |
if (!empty($regex_redirects) && is_array($regex_redirects)) { |
| 409 |
foreach ($regex_redirects as $origin => $redirect_data) { |
| 410 |
$result = $this->process_yoast_redirect_export($origin, $redirect_data, 'regex'); |
| 411 |
if ($result === true) { |
| 412 |
$imported++; |
| 413 |
} elseif ($result === 'skipped') { |
| 414 |
$skipped++; |
| 415 |
} elseif ($result === 'loop_skipped') { |
| 416 |
$loop_skipped++; |
| 417 |
} else { |
| 418 |
$errors[] = $result; |
| 419 |
} |
| 420 |
} |
| 421 |
} |
| 422 |
} |
| 423 |
|
| 424 |
# If still no redirects, try legacy options |
| 425 |
if ($imported === 0 && $skipped === 0) { |
| 426 |
# Legacy plain redirects |
| 427 |
$legacy_plain = get_option('wpseo-premium-redirects', []); |
| 428 |
if (!empty($legacy_plain) && is_array($legacy_plain)) { |
| 429 |
foreach ($legacy_plain as $origin => $redirect_data) { |
| 430 |
$result = $this->process_yoast_redirect_export($origin, $redirect_data, 'plain'); |
| 431 |
if ($result === true) { |
| 432 |
$imported++; |
| 433 |
} elseif ($result === 'skipped') { |
| 434 |
$skipped++; |
| 435 |
} elseif ($result === 'loop_skipped') { |
| 436 |
$loop_skipped++; |
| 437 |
} else { |
| 438 |
$errors[] = $result; |
| 439 |
} |
| 440 |
} |
| 441 |
} |
| 442 |
|
| 443 |
# Legacy regex redirects |
| 444 |
$legacy_regex = get_option('wpseo-premium-redirects-regex', []); |
| 445 |
if (!empty($legacy_regex) && is_array($legacy_regex)) { |
| 446 |
foreach ($legacy_regex as $origin => $redirect_data) { |
| 447 |
$result = $this->process_yoast_redirect_export($origin, $redirect_data, 'regex'); |
| 448 |
if ($result === true) { |
| 449 |
$imported++; |
| 450 |
} elseif ($result === 'skipped') { |
| 451 |
$skipped++; |
| 452 |
} elseif ($result === 'loop_skipped') { |
| 453 |
$loop_skipped++; |
| 454 |
} else { |
| 455 |
$errors[] = $result; |
| 456 |
} |
| 457 |
} |
| 458 |
} |
| 459 |
} |
| 460 |
|
| 461 |
# Return results |
| 462 |
if ($imported === 0 && $skipped === 0 && $loop_skipped === 0) { |
| 463 |
return [ |
| 464 |
'success' => false, |
| 465 |
'message' => 'No Yoast redirections found. ' . (!empty($errors) ? implode(' ', array_slice($errors, 0, 2)) : ''), |
| 466 |
'imported' => 0, |
| 467 |
'skipped' => 0, |
| 468 |
'loop_skipped' => 0, |
| 469 |
'errors' => $errors |
| 470 |
]; |
| 471 |
} |
| 472 |
|
| 473 |
$message = $imported > 0 |
| 474 |
? "Successfully imported $imported redirections from Yoast SEO." |
| 475 |
: "All redirections already exist. Skipped $skipped duplicates."; |
| 476 |
if ($loop_skipped > 0) { |
| 477 |
$message .= " Skipped $loop_skipped redirect(s) that would have created loops."; |
| 478 |
} |
| 479 |
|
| 480 |
return [ |
| 481 |
'success' => true, |
| 482 |
'message' => $message, |
| 483 |
'imported' => $imported, |
| 484 |
'skipped' => $skipped, |
| 485 |
'loop_skipped' => $loop_skipped, |
| 486 |
'errors' => $errors |
| 487 |
]; |
| 488 |
} |
| 489 |
|
| 490 |
/** |
| 491 |
* Process a redirect from Yoast base option |
| 492 |
* |
| 493 |
* @param mixed $redirect Redirect data from base option |
| 494 |
* @return bool|string True on success, 'skipped' if exists, error message on failure |
| 495 |
*/ |
| 496 |
private function process_yoast_redirect_base($redirect) |
| 497 |
{ |
| 498 |
try { |
| 499 |
# Base format: ['origin' => string, 'url' => string, 'type' => int, 'format' => string] |
| 500 |
if (!is_array($redirect)) { |
| 501 |
return 'Invalid redirect format'; |
| 502 |
} |
| 503 |
|
| 504 |
$origin = isset($redirect['origin']) ? trim($redirect['origin']) : ''; |
| 505 |
$target = isset($redirect['url']) ? trim($redirect['url']) : ''; |
| 506 |
$type = isset($redirect['type']) ? $redirect['type'] : 301; |
| 507 |
$format = isset($redirect['format']) ? $redirect['format'] : 'plain'; |
| 508 |
|
| 509 |
# Validate origin |
| 510 |
if (empty($origin)) { |
| 511 |
return 'Empty origin URL'; |
| 512 |
} |
| 513 |
|
| 514 |
# For 410 and 451, target URL can be empty (they don't redirect) |
| 515 |
if (empty($target) && !in_array($type, [410, 451])) { |
| 516 |
return 'Empty target URL for non-410/451 redirect'; |
| 517 |
} |
| 518 |
|
| 519 |
# Check if already exists |
| 520 |
if ($this->redirection_exists($origin)) { |
| 521 |
return 'skipped'; |
| 522 |
} |
| 523 |
|
| 524 |
# Determine pattern type. Foreign regex patterns get the same |
| 525 |
# ReDoS screen the admin form applies. |
| 526 |
if ($format === 'regex' && !Metasync_Redirection_Validator::is_regex_safe($origin)) { |
| 527 |
return 'skipped'; |
| 528 |
} |
| 529 |
$pattern_type = ($format === 'regex') ? 'regex' : 'exact'; |
| 530 |
$regex_pattern = ($format === 'regex') ? $origin : null; |
| 531 |
|
| 532 |
$sources = [ |
| 533 |
$origin => $pattern_type |
| 534 |
]; |
| 535 |
|
| 536 |
$args = [ |
| 537 |
'sources_from' => serialize($sources), |
| 538 |
'url_redirect_to' => $target, |
| 539 |
'http_code' => intval($type), |
| 540 |
'hits_count' => 0, |
| 541 |
'status' => 'active', |
| 542 |
'pattern_type' => $pattern_type, |
| 543 |
'regex_pattern' => $regex_pattern, |
| 544 |
'description' => 'Imported from Yoast SEO', |
| 545 |
'created_at' => current_time('mysql'), |
| 546 |
'updated_at' => current_time('mysql') |
| 547 |
]; |
| 548 |
|
| 549 |
if (!$this->is_safe_destination($target, intval($type))) { |
| 550 |
return 'skipped'; |
| 551 |
} |
| 552 |
$args['url_redirect_to'] = $target; |
| 553 |
|
| 554 |
if (!in_array(intval($type), [410, 451]) && $this->get_redirection_helper()->validate_no_loop($origin, $target) !== null) { |
| 555 |
return 'loop_skipped'; |
| 556 |
} |
| 557 |
|
| 558 |
if ($this->db_redirection->add($args)) { |
| 559 |
$this->remember_imported_source($origin); |
| 560 |
return true; |
| 561 |
} |
| 562 |
|
| 563 |
return 'Failed to insert redirect'; |
| 564 |
|
| 565 |
} catch (Exception $e) { |
| 566 |
return 'Exception: ' . $e->getMessage(); |
| 567 |
} |
| 568 |
} |
| 569 |
|
| 570 |
/** |
| 571 |
* Process a redirect from Yoast export options |
| 572 |
* |
| 573 |
* @param string $origin Source URL |
| 574 |
* @param mixed $redirect_data Redirect data from export option |
| 575 |
* @param string $format Format type ('plain' or 'regex') |
| 576 |
* @return bool|string True on success, 'skipped' if exists, error message on failure |
| 577 |
*/ |
| 578 |
private function process_yoast_redirect_export($origin, $redirect_data, $format) |
| 579 |
{ |
| 580 |
try { |
| 581 |
$origin = trim($origin); |
| 582 |
|
| 583 |
# Export format: ['url' => string, 'type' => int] |
| 584 |
if (is_array($redirect_data)) { |
| 585 |
$target = isset($redirect_data['url']) ? trim($redirect_data['url']) : ''; |
| 586 |
$type = isset($redirect_data['type']) ? $redirect_data['type'] : 301; |
| 587 |
} elseif (is_string($redirect_data)) { |
| 588 |
# Simple string format (target URL only) |
| 589 |
$target = trim($redirect_data); |
| 590 |
$type = 301; |
| 591 |
} else { |
| 592 |
return 'Invalid redirect data format'; |
| 593 |
} |
| 594 |
|
| 595 |
# Validate origin |
| 596 |
if (empty($origin)) { |
| 597 |
return 'Empty origin URL'; |
| 598 |
} |
| 599 |
|
| 600 |
# For 410 and 451, target URL can be empty (they don't redirect) |
| 601 |
if (empty($target) && !in_array($type, [410, 451])) { |
| 602 |
return 'Empty target URL for non-410/451 redirect'; |
| 603 |
} |
| 604 |
|
| 605 |
# Check if already exists |
| 606 |
if ($this->redirection_exists($origin)) { |
| 607 |
return 'skipped'; |
| 608 |
} |
| 609 |
|
| 610 |
# Determine pattern type. Foreign regex patterns get the same |
| 611 |
# ReDoS screen the admin form applies. |
| 612 |
if ($format === 'regex' && !Metasync_Redirection_Validator::is_regex_safe($origin)) { |
| 613 |
return 'skipped'; |
| 614 |
} |
| 615 |
$pattern_type = ($format === 'regex') ? 'regex' : 'exact'; |
| 616 |
$regex_pattern = ($format === 'regex') ? $origin : null; |
| 617 |
|
| 618 |
$sources = [ |
| 619 |
$origin => $pattern_type |
| 620 |
]; |
| 621 |
|
| 622 |
$args = [ |
| 623 |
'sources_from' => serialize($sources), |
| 624 |
'url_redirect_to' => $target, |
| 625 |
'http_code' => intval($type), |
| 626 |
'hits_count' => 0, |
| 627 |
'status' => 'active', |
| 628 |
'pattern_type' => $pattern_type, |
| 629 |
'regex_pattern' => $regex_pattern, |
| 630 |
'description' => 'Imported from Yoast SEO', |
| 631 |
'created_at' => current_time('mysql'), |
| 632 |
'updated_at' => current_time('mysql') |
| 633 |
]; |
| 634 |
|
| 635 |
if (!$this->is_safe_destination($target, intval($type))) { |
| 636 |
return 'skipped'; |
| 637 |
} |
| 638 |
$args['url_redirect_to'] = $target; |
| 639 |
|
| 640 |
if (!in_array(intval($type), [410, 451]) && $this->get_redirection_helper()->validate_no_loop($origin, $target) !== null) { |
| 641 |
return 'loop_skipped'; |
| 642 |
} |
| 643 |
|
| 644 |
if ($this->db_redirection->add($args)) { |
| 645 |
$this->remember_imported_source($origin); |
| 646 |
return true; |
| 647 |
} |
| 648 |
|
| 649 |
return 'Failed to insert redirect'; |
| 650 |
|
| 651 |
} catch (Exception $e) { |
| 652 |
return 'Exception: ' . $e->getMessage(); |
| 653 |
} |
| 654 |
} |
| 655 |
|
| 656 |
/** |
| 657 |
* Import from Rank Math |
| 658 |
*/ |
| 659 |
private function import_from_rankmath() |
| 660 |
{ |
| 661 |
global $wpdb; |
| 662 |
$table_name = $wpdb->prefix . 'rank_math_redirections'; |
| 663 |
|
| 664 |
if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table_name))) !== $table_name) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists |
| 665 |
return [ |
| 666 |
'success' => false, |
| 667 |
'message' => 'Rank Math redirections table not found.', |
| 668 |
'imported' => 0, |
| 669 |
'skipped' => 0, |
| 670 |
'loop_skipped' => 0, |
| 671 |
'errors' => ['Table not found: ' . $table_name] |
| 672 |
]; |
| 673 |
} |
| 674 |
|
| 675 |
# Get all redirections (not just active ones, Rank Math uses different status field) |
| 676 |
$redirects = $wpdb->get_results("SELECT * FROM {$wpdb->prefix}rank_math_redirections"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists |
| 677 |
|
| 678 |
if (empty($redirects)) { |
| 679 |
return [ |
| 680 |
'success' => false, |
| 681 |
'message' => 'No redirections found in Rank Math.', |
| 682 |
'imported' => 0, |
| 683 |
'skipped' => 0, |
| 684 |
'loop_skipped' => 0, |
| 685 |
'errors' => [] |
| 686 |
]; |
| 687 |
} |
| 688 |
|
| 689 |
$imported = 0; |
| 690 |
$skipped = 0; |
| 691 |
$errors = []; |
| 692 |
$loop_skipped = 0; |
| 693 |
$parse_skipped = 0; |
| 694 |
$unsafe_skipped = 0; |
| 695 |
$inactive_skipped = 0; |
| 696 |
|
| 697 |
# Exact-key index of every source already stored — one pass instead of a |
| 698 |
# per-row LIKE rescan, with no substring false-positives. |
| 699 |
$existing_sources = $this->get_existing_source_index(); |
| 700 |
|
| 701 |
foreach ($redirects as $redirect) { |
| 702 |
try { |
| 703 |
# Rank Math keeps disabled redirects in the same table; only |
| 704 |
# enabled rows must come across. |
| 705 |
if (isset($redirect->status) && !in_array((string) $redirect->status, ['active', 'enabled', '1', ''], true)) { |
| 706 |
$inactive_skipped++; |
| 707 |
continue; |
| 708 |
} |
| 709 |
|
| 710 |
# Rank Math uses 'sources' field - it's a serialized PHP array of |
| 711 |
# ['pattern' => …, 'comparison' => …] entries. Keep ALL of them: |
| 712 |
# taking only the first silently dropped every extra source. |
| 713 |
$row_sources = []; |
| 714 |
|
| 715 |
if (isset($redirect->sources)) { |
| 716 |
$unserialized = @maybe_unserialize($redirect->sources); |
| 717 |
|
| 718 |
if (!is_array($unserialized) || empty($unserialized)) { |
| 719 |
# Try as JSON before giving up on the blob |
| 720 |
$parsed = json_decode((string) $redirect->sources, true); |
| 721 |
if (json_last_error() === JSON_ERROR_NONE && is_array($parsed) && !empty($parsed)) { |
| 722 |
$unserialized = $parsed; |
| 723 |
} |
| 724 |
} |
| 725 |
|
| 726 |
if (is_array($unserialized) && !empty($unserialized)) { |
| 727 |
foreach ($unserialized as $source_entry) { |
| 728 |
if (is_array($source_entry)) { |
| 729 |
if (empty($source_entry['pattern']) || !is_string($source_entry['pattern'])) { |
| 730 |
continue; |
| 731 |
} |
| 732 |
$row_sources[trim($source_entry['pattern'])] = isset($source_entry['comparison']) |
| 733 |
? (string) $source_entry['comparison'] |
| 734 |
: 'exact'; |
| 735 |
} elseif (is_string($source_entry) && trim($source_entry) !== '') { |
| 736 |
$row_sources[trim($source_entry)] = 'exact'; |
| 737 |
} |
| 738 |
} |
| 739 |
} elseif (is_string($unserialized) && trim($unserialized) !== '') { |
| 740 |
# Direct string value |
| 741 |
$row_sources[trim($unserialized)] = 'exact'; |
| 742 |
} |
| 743 |
} |
| 744 |
|
| 745 |
if (empty($row_sources) && isset($redirect->url_from) && trim((string) $redirect->url_from) !== '') { |
| 746 |
$row_sources[trim((string) $redirect->url_from)] = 'exact'; |
| 747 |
} |
| 748 |
|
| 749 |
if (empty($row_sources)) { |
| 750 |
$errors[] = 'Empty source URL in redirect ID: ' . ($redirect->id ?? 'unknown'); |
| 751 |
$parse_skipped++; |
| 752 |
continue; |
| 753 |
} |
| 754 |
|
| 755 |
# Map Rank Math comparison types to MetaSync pattern types |
| 756 |
# Rank Math: exact, regex, contains, starts, ends |
| 757 |
# MetaSync: exact, regex, contain, start, end |
| 758 |
$pattern_type_map = [ |
| 759 |
'exact' => 'exact', |
| 760 |
'regex' => 'regex', |
| 761 |
'contains' => 'contain', |
| 762 |
'starts' => 'start', |
| 763 |
'ends' => 'end' |
| 764 |
]; |
| 765 |
|
| 766 |
# Drop sources that already exist; if every source is a duplicate |
| 767 |
# the whole row is one, otherwise import the remainder. |
| 768 |
$sources = []; |
| 769 |
foreach ($row_sources as $source_pattern => $comparison_type) { |
| 770 |
if (isset($existing_sources[$source_pattern])) { |
| 771 |
continue; |
| 772 |
} |
| 773 |
$sources[$source_pattern] = isset($pattern_type_map[$comparison_type]) |
| 774 |
? $pattern_type_map[$comparison_type] |
| 775 |
: 'exact'; |
| 776 |
} |
| 777 |
|
| 778 |
if (empty($sources)) { |
| 779 |
$skipped++; |
| 780 |
continue; |
| 781 |
} |
| 782 |
|
| 783 |
# Row-level pattern type only matters when there is a single source; |
| 784 |
# with several, per-source types in the serialized map drive matching. |
| 785 |
$pattern_type = count($sources) === 1 ? reset($sources) : 'exact'; |
| 786 |
|
| 787 |
# For regex patterns, store the first regex source as the pattern |
| 788 |
$regex_pattern = null; |
| 789 |
if ($pattern_type === 'regex') { |
| 790 |
$regex_pattern = (string) key($sources); |
| 791 |
if (!Metasync_Redirection_Validator::is_regex_safe($regex_pattern)) { |
| 792 |
$errors[] = 'Skipped unsafe regex pattern: ' . $regex_pattern; |
| 793 |
$parse_skipped++; |
| 794 |
continue; |
| 795 |
} |
| 796 |
} |
| 797 |
|
| 798 |
$source_url = (string) key($sources); |
| 799 |
|
| 800 |
$target_url = $redirect->url_to ?? ''; |
| 801 |
$http_code = intval($redirect->header_code ?? 301); |
| 802 |
|
| 803 |
# For 410 and 451, target URL can be empty (they don't redirect) |
| 804 |
if (empty($target_url) && !in_array($http_code, [410, 451])) { |
| 805 |
$errors[] = 'Empty target URL for source: ' . $source_url; |
| 806 |
$parse_skipped++; |
| 807 |
continue; |
| 808 |
} |
| 809 |
|
| 810 |
$args = [ |
| 811 |
'sources_from' => serialize($sources), |
| 812 |
'url_redirect_to' => $target_url, |
| 813 |
'http_code' => $http_code, |
| 814 |
'hits_count' => $redirect->hits ?? 0, |
| 815 |
'status' => 'active', |
| 816 |
'pattern_type' => $pattern_type, |
| 817 |
'regex_pattern' => $regex_pattern, |
| 818 |
'description' => 'Imported from Rank Math', |
| 819 |
'created_at' => current_time('mysql'), |
| 820 |
'updated_at' => current_time('mysql') |
| 821 |
]; |
| 822 |
|
| 823 |
if (!$this->is_safe_destination($target_url, $http_code)) { |
| 824 |
$unsafe_skipped++; |
| 825 |
continue; |
| 826 |
} |
| 827 |
$args['url_redirect_to'] = $target_url; |
| 828 |
|
| 829 |
if (!in_array($http_code, [410, 451])) { |
| 830 |
foreach (array_keys($sources) as $loop_source) { |
| 831 |
if ($this->get_redirection_helper()->validate_no_loop($loop_source, $target_url) !== null) { |
| 832 |
$loop_skipped++; |
| 833 |
continue 2; |
| 834 |
} |
| 835 |
} |
| 836 |
} |
| 837 |
|
| 838 |
$result = $this->db_redirection->add($args); |
| 839 |
|
| 840 |
if ($result !== false && $result > 0) { |
| 841 |
$imported++; |
| 842 |
foreach (array_keys($sources) as $imported_source) { |
| 843 |
$existing_sources[$imported_source] = true; |
| 844 |
$this->remember_imported_source($imported_source); |
| 845 |
} |
| 846 |
} else { |
| 847 |
$errors[] = 'Failed to insert redirect: ' . $source_url . ' -> ' . $target_url; |
| 848 |
if ($wpdb->last_error) { |
| 849 |
$errors[] = 'DB Error: ' . $wpdb->last_error; |
| 850 |
} |
| 851 |
} |
| 852 |
|
| 853 |
} catch (Exception $e) { |
| 854 |
$errors[] = 'Exception: ' . $e->getMessage(); |
| 855 |
$skipped++; |
| 856 |
} |
| 857 |
} |
| 858 |
|
| 859 |
# Return success if we processed redirections (even if all skipped) |
| 860 |
$has_results = ($imported + $skipped + $loop_skipped + $parse_skipped + $unsafe_skipped + $inactive_skipped) > 0; |
| 861 |
|
| 862 |
$message = $imported > 0 |
| 863 |
? "Successfully imported $imported redirections from Rank Math." |
| 864 |
: ($skipped > 0 |
| 865 |
? "All redirections already exist. Skipped $skipped duplicates." |
| 866 |
: "No redirections found to import. " . (count($errors) > 0 ? implode(' ', array_slice($errors, 0, 2)) : '')); |
| 867 |
if ($inactive_skipped > 0) { |
| 868 |
$message .= " Skipped $inactive_skipped disabled redirection(s)."; |
| 869 |
} |
| 870 |
if ($parse_skipped > 0) { |
| 871 |
$message .= " Skipped $parse_skipped redirection(s) with unreadable source or destination."; |
| 872 |
} |
| 873 |
if ($unsafe_skipped > 0) { |
| 874 |
$message .= " Skipped $unsafe_skipped redirection(s) with an off-site destination."; |
| 875 |
} |
| 876 |
if ($loop_skipped > 0) { |
| 877 |
$message .= " Skipped $loop_skipped redirect(s) that would have created loops."; |
| 878 |
} |
| 879 |
|
| 880 |
return [ |
| 881 |
'success' => $has_results, |
| 882 |
'message' => $message, |
| 883 |
'imported' => $imported, |
| 884 |
'skipped' => $skipped, |
| 885 |
'loop_skipped' => $loop_skipped, |
| 886 |
'skipped_parse' => $parse_skipped, |
| 887 |
'skipped_unsafe' => $unsafe_skipped, |
| 888 |
'skipped_inactive' => $inactive_skipped, |
| 889 |
'errors' => $errors |
| 890 |
]; |
| 891 |
} |
| 892 |
|
| 893 |
/** |
| 894 |
* Import from All in One SEO |
| 895 |
*/ |
| 896 |
private function import_from_aioseo() |
| 897 |
{ |
| 898 |
global $wpdb; |
| 899 |
$table_name = $wpdb->prefix . 'aioseo_redirects'; |
| 900 |
|
| 901 |
if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table_name))) !== $table_name) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists |
| 902 |
return [ |
| 903 |
'success' => false, |
| 904 |
'message' => 'All in One SEO redirects table not found.', |
| 905 |
'imported' => 0, |
| 906 |
'skipped' => 0, |
| 907 |
'loop_skipped' => 0 |
| 908 |
]; |
| 909 |
} |
| 910 |
|
| 911 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists |
| 912 |
$redirects = $wpdb->get_results(" |
| 913 |
SELECT * FROM {$wpdb->prefix}aioseo_redirects |
| 914 |
WHERE enabled = 1 |
| 915 |
"); |
| 916 |
|
| 917 |
$imported = 0; |
| 918 |
$skipped = 0; |
| 919 |
$loop_skipped = 0; |
| 920 |
$parse_skipped = 0; |
| 921 |
|
| 922 |
foreach ($redirects as $redirect) { |
| 923 |
$http_code = intval($redirect->redirect_code ?? 301); |
| 924 |
$target_url = $redirect->target_url ?? ''; |
| 925 |
|
| 926 |
# For 410 and 451, target URL can be empty (they don't redirect) |
| 927 |
if (empty($target_url) && !in_array($http_code, [410, 451])) { |
| 928 |
$skipped++; |
| 929 |
continue; |
| 930 |
} |
| 931 |
|
| 932 |
# Check if already exists |
| 933 |
if ($this->redirection_exists($redirect->source_url)) { |
| 934 |
$skipped++; |
| 935 |
continue; |
| 936 |
} |
| 937 |
|
| 938 |
# Determine pattern type |
| 939 |
$pattern_type = 'exact'; |
| 940 |
$regex_pattern = null; |
| 941 |
|
| 942 |
if (isset($redirect->regex) && $redirect->regex == 1) { |
| 943 |
if (!Metasync_Redirection_Validator::is_regex_safe((string) $redirect->source_url)) { |
| 944 |
$parse_skipped++; |
| 945 |
continue; |
| 946 |
} |
| 947 |
$pattern_type = 'regex'; |
| 948 |
$regex_pattern = $redirect->source_url; |
| 949 |
} |
| 950 |
|
| 951 |
$sources = [ |
| 952 |
$redirect->source_url => $pattern_type |
| 953 |
]; |
| 954 |
|
| 955 |
$args = [ |
| 956 |
'sources_from' => serialize($sources), |
| 957 |
'url_redirect_to' => $target_url, |
| 958 |
'http_code' => $http_code, |
| 959 |
'hits_count' => $redirect->hits ?? 0, |
| 960 |
'status' => 'active', |
| 961 |
'pattern_type' => $pattern_type, |
| 962 |
'regex_pattern' => $regex_pattern, |
| 963 |
'description' => 'Imported from All in One SEO', |
| 964 |
'created_at' => current_time('mysql'), |
| 965 |
'updated_at' => current_time('mysql') |
| 966 |
]; |
| 967 |
|
| 968 |
if (!$this->is_safe_destination($target_url, $http_code)) { |
| 969 |
$skipped++; |
| 970 |
continue; |
| 971 |
} |
| 972 |
$args['url_redirect_to'] = $target_url; |
| 973 |
|
| 974 |
if (!in_array($http_code, [410, 451]) && $this->get_redirection_helper()->validate_no_loop($redirect->source_url, $target_url) !== null) { |
| 975 |
$loop_skipped++; |
| 976 |
continue; |
| 977 |
} |
| 978 |
|
| 979 |
if ($this->db_redirection->add($args)) { |
| 980 |
$imported++; |
| 981 |
$this->remember_imported_source($redirect->source_url); |
| 982 |
} |
| 983 |
} |
| 984 |
|
| 985 |
$message = "Successfully imported $imported redirections from All in One SEO."; |
| 986 |
if ($parse_skipped > 0) { |
| 987 |
$message .= " Skipped $parse_skipped redirection(s) with an unreadable source or destination."; |
| 988 |
} |
| 989 |
if ($loop_skipped > 0) { |
| 990 |
$message .= " Skipped $loop_skipped redirect(s) that would have created loops."; |
| 991 |
} |
| 992 |
|
| 993 |
return [ |
| 994 |
'success' => true, |
| 995 |
'message' => $message, |
| 996 |
'imported' => $imported, |
| 997 |
'skipped' => $skipped, |
| 998 |
'loop_skipped' => $loop_skipped, |
| 999 |
'skipped_parse' => $parse_skipped |
| 1000 |
]; |
| 1001 |
} |
| 1002 |
|
| 1003 |
/** |
| 1004 |
* Import from Simple 301 Redirects |
| 1005 |
*/ |
| 1006 |
private function import_from_simple301() |
| 1007 |
{ |
| 1008 |
# Simple 301 Redirects stores in options |
| 1009 |
$redirects = get_option('301_redirects', []); |
| 1010 |
|
| 1011 |
if (empty($redirects)) { |
| 1012 |
return [ |
| 1013 |
'success' => false, |
| 1014 |
'message' => 'No Simple 301 Redirects found.', |
| 1015 |
'imported' => 0, |
| 1016 |
'skipped' => 0, |
| 1017 |
'loop_skipped' => 0 |
| 1018 |
]; |
| 1019 |
} |
| 1020 |
|
| 1021 |
$imported = 0; |
| 1022 |
$skipped = 0; |
| 1023 |
$loop_skipped = 0; |
| 1024 |
|
| 1025 |
foreach ($redirects as $old_url => $new_url) { |
| 1026 |
# Skip empty entries |
| 1027 |
if (empty($old_url) || empty($new_url)) { |
| 1028 |
$skipped++; |
| 1029 |
continue; |
| 1030 |
} |
| 1031 |
|
| 1032 |
# Check if already exists |
| 1033 |
if ($this->redirection_exists($old_url)) { |
| 1034 |
$skipped++; |
| 1035 |
continue; |
| 1036 |
} |
| 1037 |
|
| 1038 |
$sources = [ |
| 1039 |
$old_url => 'exact' |
| 1040 |
]; |
| 1041 |
|
| 1042 |
$args = [ |
| 1043 |
'sources_from' => serialize($sources), |
| 1044 |
'url_redirect_to' => $new_url, |
| 1045 |
'http_code' => 301, |
| 1046 |
'hits_count' => 0, |
| 1047 |
'status' => 'active', |
| 1048 |
'pattern_type' => 'exact', |
| 1049 |
'regex_pattern' => null, |
| 1050 |
'description' => 'Imported from Simple 301 Redirects', |
| 1051 |
'created_at' => current_time('mysql'), |
| 1052 |
'updated_at' => current_time('mysql') |
| 1053 |
]; |
| 1054 |
|
| 1055 |
if (!$this->is_safe_destination($new_url, 301)) { |
| 1056 |
$skipped++; |
| 1057 |
continue; |
| 1058 |
} |
| 1059 |
$args['url_redirect_to'] = $new_url; |
| 1060 |
|
| 1061 |
if ($this->get_redirection_helper()->validate_no_loop($old_url, $new_url) !== null) { |
| 1062 |
$loop_skipped++; |
| 1063 |
continue; |
| 1064 |
} |
| 1065 |
|
| 1066 |
if ($this->db_redirection->add($args)) { |
| 1067 |
$imported++; |
| 1068 |
$this->remember_imported_source($old_url); |
| 1069 |
} |
| 1070 |
} |
| 1071 |
|
| 1072 |
$message = "Successfully imported $imported redirections from Simple 301 Redirects."; |
| 1073 |
if ($loop_skipped > 0) { |
| 1074 |
$message .= " Skipped $loop_skipped redirect(s) that would have created loops."; |
| 1075 |
} |
| 1076 |
|
| 1077 |
return [ |
| 1078 |
'success' => true, |
| 1079 |
'message' => $message, |
| 1080 |
'imported' => $imported, |
| 1081 |
'skipped' => $skipped, |
| 1082 |
'loop_skipped' => $loop_skipped |
| 1083 |
]; |
| 1084 |
} |
| 1085 |
|
| 1086 |
/** |
| 1087 |
* Reject off-site destination URLs at import time so attacker-controlled |
| 1088 |
* exports cannot smuggle external redirects in via plugin migration. |
| 1089 |
* |
| 1090 |
* 410 and 451 entries have no destination, so they are always safe. |
| 1091 |
* |
| 1092 |
* @param string $url Destination URL from the source plugin. |
| 1093 |
* @param int $http_code HTTP status the redirect will serve. |
| 1094 |
* @return bool True when the destination is empty-by-design or resolves on-site. |
| 1095 |
*/ |
| 1096 |
private function is_safe_destination(&$url, $http_code) |
| 1097 |
{ |
| 1098 |
if (in_array(intval($http_code), [410, 451], true)) { |
| 1099 |
$url = ''; |
| 1100 |
return true; |
| 1101 |
} |
| 1102 |
|
| 1103 |
$url = (string) $url; |
| 1104 |
if ($url === '') { |
| 1105 |
return false; |
| 1106 |
} |
| 1107 |
|
| 1108 |
# Backslashes never belong in a URL, but browsers read them as path |
| 1109 |
# separators: '/\evil.com' slips past wp_validate_redirect (parse_url |
| 1110 |
# sees no host) and still navigates off-site. Reject outright. |
| 1111 |
if (!Metasync_Redirection_Validator::is_safe_destination_syntax($url)) { |
| 1112 |
return false; |
| 1113 |
} |
| 1114 |
|
| 1115 |
if (get_option('metasync_allow_external_redirects', 0)) { |
| 1116 |
return true; |
| 1117 |
} |
| 1118 |
|
| 1119 |
return wp_validate_redirect($url, '') !== ''; |
| 1120 |
} |
| 1121 |
|
| 1122 |
/** |
| 1123 |
* Extract the destination URL from the Redirection plugin's action_data column. |
| 1124 |
* |
| 1125 |
* action_data is normally a JSON envelope ('{"url":"https://…"}'); older rows |
| 1126 |
* and other action types store plain strings, arrays or serialized values. |
| 1127 |
* Storing the raw envelope as the destination makes the redirect point at a |
| 1128 |
* literal '{"url":…}' string, so parse the envelope and only accept a URL. |
| 1129 |
* |
| 1130 |
* @param mixed $action_data Raw action_data value from the source table. |
| 1131 |
* @return string|null Destination URL ('' when absent), or null when unparseable. |
| 1132 |
*/ |
| 1133 |
private function extract_destination($action_data) |
| 1134 |
{ |
| 1135 |
if ($action_data === null || $action_data === '') { |
| 1136 |
return ''; |
| 1137 |
} |
| 1138 |
|
| 1139 |
$value = $action_data; |
| 1140 |
|
| 1141 |
if (is_string($value)) { |
| 1142 |
$trimmed = trim($value); |
| 1143 |
if ($trimmed === '') { |
| 1144 |
return ''; |
| 1145 |
} |
| 1146 |
if ($trimmed[0] === '{' || $trimmed[0] === '[') { |
| 1147 |
$decoded = json_decode($trimmed, true); |
| 1148 |
if (json_last_error() !== JSON_ERROR_NONE) { |
| 1149 |
return null; |
| 1150 |
} |
| 1151 |
$value = $decoded; |
| 1152 |
} elseif (str_starts_with($trimmed, 'a:') || str_starts_with($trimmed, 's:')) { |
| 1153 |
$unserialized = @unserialize($trimmed, ['allowed_classes' => false]); |
| 1154 |
$value = ($unserialized !== false || $trimmed === 'b:0;') ? $unserialized : null; |
| 1155 |
} else { |
| 1156 |
return $trimmed; |
| 1157 |
} |
| 1158 |
} |
| 1159 |
|
| 1160 |
if (is_array($value)) { |
| 1161 |
if (isset($value['url']) && is_string($value['url'])) { |
| 1162 |
return trim($value['url']); |
| 1163 |
} |
| 1164 |
$first = reset($value); |
| 1165 |
if (is_array($first) && isset($first['url']) && is_string($first['url'])) { |
| 1166 |
return trim($first['url']); |
| 1167 |
} |
| 1168 |
if (is_string($first)) { |
| 1169 |
return trim($first); |
| 1170 |
} |
| 1171 |
return null; |
| 1172 |
} |
| 1173 |
|
| 1174 |
return is_string($value) ? trim($value) : null; |
| 1175 |
} |
| 1176 |
|
| 1177 |
/** |
| 1178 |
* Build an exact-key index of every source URL already stored in the |
| 1179 |
* redirections table. |
| 1180 |
* |
| 1181 |
* One SELECT plus one unserialize pass replaces the previous per-row |
| 1182 |
* `sources_from LIKE '%…%'` lookup, which rescanned the whole table for |
| 1183 |
* every imported row (O(N²) on large tables) and matched substrings — |
| 1184 |
* importing /a reported the existing /about as a duplicate. |
| 1185 |
* |
| 1186 |
* Legacy rows that predate keyed sources store a numeric list; for those |
| 1187 |
* the VALUE is the source URL. |
| 1188 |
* |
| 1189 |
* @return array<string, true> Source URL/path keys. |
| 1190 |
*/ |
| 1191 |
private function get_existing_source_index() |
| 1192 |
{ |
| 1193 |
if ($this->source_index_cache !== null) { |
| 1194 |
return $this->source_index_cache; |
| 1195 |
} |
| 1196 |
|
| 1197 |
$index = []; |
| 1198 |
$rows = $this->db_redirection->getAllRecords(); |
| 1199 |
if (empty($rows)) { |
| 1200 |
return $index; |
| 1201 |
} |
| 1202 |
|
| 1203 |
foreach ($rows as $row) { |
| 1204 |
$sources = !empty($row->sources_from) |
| 1205 |
? @unserialize($row->sources_from, ['allowed_classes' => false]) |
| 1206 |
: []; |
| 1207 |
if (!is_array($sources)) { |
| 1208 |
continue; |
| 1209 |
} |
| 1210 |
foreach ($sources as $source_key => $source_value) { |
| 1211 |
// Numeric keys mark legacy list rows; the value is the URL there. |
| 1212 |
$key = is_int($source_key) ? (string) $source_value : (string) $source_key; |
| 1213 |
if ($key !== '') { |
| 1214 |
$index[$key] = true; |
| 1215 |
} |
| 1216 |
} |
| 1217 |
} |
| 1218 |
|
| 1219 |
$this->source_index_cache = $index; |
| 1220 |
|
| 1221 |
return $index; |
| 1222 |
} |
| 1223 |
|
| 1224 |
/** |
| 1225 |
* Record a source imported during this request so the memoized index — |
| 1226 |
* and with it every later duplicate check — knows about it. |
| 1227 |
* |
| 1228 |
* @param string $source Source URL/path just stored. |
| 1229 |
*/ |
| 1230 |
private function remember_imported_source($source) |
| 1231 |
{ |
| 1232 |
$source = (string) $source; |
| 1233 |
if ($source !== '' && $this->source_index_cache !== null) { |
| 1234 |
$this->source_index_cache[$source] = true; |
| 1235 |
} |
| 1236 |
} |
| 1237 |
|
| 1238 |
/** |
| 1239 |
* Import redirections from an uploaded CSV file. |
| 1240 |
* |
| 1241 |
* The readme has advertised ".csv file" import since the importer UI |
| 1242 |
* shipped; this is the implementation. Expected columns per row: |
| 1243 |
* source, destination, http code (optional, 301 by default). A header |
| 1244 |
* row is detected and skipped, and every row passes the same dedup, |
| 1245 |
* off-site, and loop guards the plugin imports use. |
| 1246 |
* |
| 1247 |
* @param string $file_path Path to the uploaded CSV temp file. |
| 1248 |
* @return array Result with success status, message, and counters. |
| 1249 |
*/ |
| 1250 |
public function import_csv_file($file_path) |
| 1251 |
{ |
| 1252 |
$handle = @fopen($file_path, 'r'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming file I/O; WP_Filesystem cannot return a raw stream handle |
| 1253 |
|
| 1254 |
if (!$handle) { |
| 1255 |
return [ |
| 1256 |
'success' => false, |
| 1257 |
'message' => 'Could not read the uploaded CSV file.', |
| 1258 |
'imported' => 0, |
| 1259 |
'skipped' => 0, |
| 1260 |
'loop_skipped' => 0 |
| 1261 |
]; |
| 1262 |
} |
| 1263 |
|
| 1264 |
$imported = 0; |
| 1265 |
$skipped = 0; |
| 1266 |
$parse_skipped = 0; |
| 1267 |
$unsafe_skipped = 0; |
| 1268 |
$loop_skipped = 0; |
| 1269 |
$row_index = 0; |
| 1270 |
$truncated = false; |
| 1271 |
|
| 1272 |
$allowed_codes = [301, 302, 307, 410, 451]; |
| 1273 |
$existing_sources = $this->get_existing_source_index(); |
| 1274 |
|
| 1275 |
while (($raw = fgets($handle)) !== false) { |
| 1276 |
# Bound the run: an oversized or malformed export must not turn |
| 1277 |
# into an unbounded loop over the temp file. |
| 1278 |
if ($row_index >= self::CSV_MAX_ROWS) { |
| 1279 |
$truncated = true; |
| 1280 |
break; |
| 1281 |
} |
| 1282 |
|
| 1283 |
$row = str_getcsv($raw, ',', '"', '\\'); |
| 1284 |
|
| 1285 |
# Header row ("source,destination,...") — skip it once. |
| 1286 |
if ($row_index === 0 && isset($row[0]) && in_array(strtolower(trim((string) $row[0])), ['source', 'origin', 'from'], true)) { |
| 1287 |
$row_index++; |
| 1288 |
continue; |
| 1289 |
} |
| 1290 |
|
| 1291 |
$row_index++; |
| 1292 |
|
| 1293 |
# str_getcsv() always returns an array, so a short row is the only |
| 1294 |
# unusable shape to screen for here. |
| 1295 |
if (count($row) < 2) { |
| 1296 |
if (trim((string) $raw) !== '') { |
| 1297 |
$parse_skipped++; |
| 1298 |
} |
| 1299 |
continue; |
| 1300 |
} |
| 1301 |
|
| 1302 |
$source = trim((string) $row[0]); |
| 1303 |
$target = trim((string) $row[1]); |
| 1304 |
$http_code = isset($row[2]) && trim((string) $row[2]) !== '' ? (int) trim((string) $row[2]) : 301; |
| 1305 |
|
| 1306 |
if ($source === '' || !in_array($http_code, $allowed_codes, true) || ($target === '' && !in_array($http_code, [410, 451], true))) { |
| 1307 |
$parse_skipped++; |
| 1308 |
continue; |
| 1309 |
} |
| 1310 |
|
| 1311 |
# Skip rows whose source already has a rule. |
| 1312 |
if (isset($existing_sources[(string) $source])) { |
| 1313 |
$skipped++; |
| 1314 |
continue; |
| 1315 |
} |
| 1316 |
|
| 1317 |
$sources = [ |
| 1318 |
$source => 'exact' |
| 1319 |
]; |
| 1320 |
|
| 1321 |
$args = [ |
| 1322 |
'sources_from' => serialize($sources), |
| 1323 |
'url_redirect_to' => $target, |
| 1324 |
'http_code' => $http_code, |
| 1325 |
'hits_count' => 0, |
| 1326 |
'status' => 'active', |
| 1327 |
'pattern_type' => 'exact', |
| 1328 |
'regex_pattern' => null, |
| 1329 |
'description' => 'Imported from CSV file', |
| 1330 |
'created_at' => current_time('mysql'), |
| 1331 |
'updated_at' => current_time('mysql') |
| 1332 |
]; |
| 1333 |
|
| 1334 |
if (!$this->is_safe_destination($target, $http_code)) { |
| 1335 |
$unsafe_skipped++; |
| 1336 |
continue; |
| 1337 |
} |
| 1338 |
$args['url_redirect_to'] = $target; |
| 1339 |
|
| 1340 |
if (!in_array($http_code, [410, 451], true) && $this->get_redirection_helper()->validate_no_loop($source, $target) !== null) { |
| 1341 |
$loop_skipped++; |
| 1342 |
continue; |
| 1343 |
} |
| 1344 |
|
| 1345 |
if ($this->db_redirection->add($args)) { |
| 1346 |
$imported++; |
| 1347 |
$existing_sources[(string) $source] = true; |
| 1348 |
$this->remember_imported_source($source); |
| 1349 |
} |
| 1350 |
} |
| 1351 |
fclose($handle); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- operates on a native stream handle |
| 1352 |
|
| 1353 |
if ($imported === 0 && $skipped === 0 && $parse_skipped === 0 && $unsafe_skipped === 0 && $loop_skipped === 0) { |
| 1354 |
return [ |
| 1355 |
'success' => false, |
| 1356 |
'message' => 'No redirections found in the CSV file. Expected columns: source, destination, http code (optional).', |
| 1357 |
'imported' => 0, |
| 1358 |
'skipped' => 0, |
| 1359 |
'loop_skipped' => 0 |
| 1360 |
]; |
| 1361 |
} |
| 1362 |
|
| 1363 |
$message = "Successfully imported $imported redirections from CSV."; |
| 1364 |
if ($skipped > 0) { |
| 1365 |
$message .= " Skipped $skipped already-existing redirection(s)."; |
| 1366 |
} |
| 1367 |
if ($parse_skipped > 0) { |
| 1368 |
$message .= " Skipped $parse_skipped invalid redirection(s)."; |
| 1369 |
} |
| 1370 |
if ($unsafe_skipped > 0) { |
| 1371 |
$message .= " Skipped $unsafe_skipped redirection(s) with an off-site destination."; |
| 1372 |
} |
| 1373 |
if ($loop_skipped > 0) { |
| 1374 |
$message .= " Skipped $loop_skipped redirect(s) that would have created loops."; |
| 1375 |
} |
| 1376 |
if ($truncated) { |
| 1377 |
$message .= ' Import stopped after ' . self::CSV_MAX_ROWS . ' rows; split larger files and import them separately.'; |
| 1378 |
} |
| 1379 |
|
| 1380 |
return [ |
| 1381 |
'success' => true, |
| 1382 |
'message' => $message, |
| 1383 |
'imported' => $imported, |
| 1384 |
'skipped' => $skipped, |
| 1385 |
'loop_skipped' => $loop_skipped, |
| 1386 |
'skipped_parse' => $parse_skipped, |
| 1387 |
'skipped_unsafe' => $unsafe_skipped, |
| 1388 |
'truncated' => $truncated |
| 1389 |
]; |
| 1390 |
} |
| 1391 |
|
| 1392 |
/** |
| 1393 |
* Check whether a redirection already exists for a source. |
| 1394 |
* |
| 1395 |
* Exact key match against the memoized index; never a substring test, |
| 1396 |
* so importing /a no longer "matches" an existing /about. |
| 1397 |
* |
| 1398 |
* @param string $source_url Source URL to check. |
| 1399 |
* @return bool True if a redirect already stores this exact source. |
| 1400 |
*/ |
| 1401 |
private function redirection_exists($source_url) |
| 1402 |
{ |
| 1403 |
$index = $this->get_existing_source_index(); |
| 1404 |
return isset($index[(string) $source_url]); |
| 1405 |
} |
| 1406 |
|
| 1407 |
/** |
| 1408 |
* Get import statistics |
| 1409 |
* |
| 1410 |
* @return array Statistics about imports |
| 1411 |
*/ |
| 1412 |
public function get_import_stats() |
| 1413 |
{ |
| 1414 |
global $wpdb; |
| 1415 |
$table_name = $wpdb->prefix . Metasync_Redirection_Database::$table_name; |
| 1416 |
|
| 1417 |
$stats = [ |
| 1418 |
'total_redirections' => 0, |
| 1419 |
'imported_redirections' => 0 |
| 1420 |
]; |
| 1421 |
|
| 1422 |
$stats['total_redirections'] = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}aioseo_redirects"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists |
| 1423 |
|
| 1424 |
$stats['imported_redirections'] = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party redirect exports; no bulk WordPress API exists |
| 1425 |
"SELECT COUNT(*) FROM {$wpdb->prefix}aioseo_redirects WHERE description LIKE %s", |
| 1426 |
'Imported from%' |
| 1427 |
)); |
| 1428 |
|
| 1429 |
return $stats; |
| 1430 |
} |
| 1431 |
} |
| 1432 |
|