| 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 |
class Metasync_Redirection_Importer |
| 19 |
{ |
| 20 |
private $db_redirection; |
| 21 |
|
| 22 |
private $redirection_helper = null; |
| 23 |
|
| 24 |
/** |
| 25 |
* Supported plugins for import |
| 26 |
*/ |
| 27 |
const SUPPORTED_PLUGINS = [ |
| 28 |
'redirection' => [ |
| 29 |
'name' => 'Redirection', |
| 30 |
'slug' => 'redirection/redirection.php', |
| 31 |
'table' => 'redirection_items' |
| 32 |
], |
| 33 |
'yoast' => [ |
| 34 |
'name' => 'Yoast SEO Premium', |
| 35 |
'slugs' => [ |
| 36 |
'wordpress-seo-premium/wp-seo-premium.php', |
| 37 |
'wordpress-seo-premium-main/wp-seo-premium.php', |
| 38 |
'wordpress-seo/wp-seo.php' |
| 39 |
], |
| 40 |
'options' => [ |
| 41 |
'wpseo-premium-redirects-base', |
| 42 |
'wpseo-premium-redirects-export-plain', |
| 43 |
'wpseo-premium-redirects-export-regex', |
| 44 |
'wpseo-premium-redirects', |
| 45 |
'wpseo-premium-redirects-regex' |
| 46 |
] |
| 47 |
], |
| 48 |
'rankmath' => [ |
| 49 |
'name' => 'Rank Math', |
| 50 |
'slug' => 'seo-by-rank-math/rank-math.php', |
| 51 |
'table' => 'rank_math_redirections' |
| 52 |
], |
| 53 |
'aioseo' => [ |
| 54 |
'name' => 'All in One SEO', |
| 55 |
'slug' => 'all-in-one-seo-pack/all_in_one_seo_pack.php', |
| 56 |
'table' => 'aioseo_redirects' |
| 57 |
], |
| 58 |
'simple301' => [ |
| 59 |
'name' => 'Simple 301 Redirects', |
| 60 |
'slug' => 'simple-301-redirects/simple-301-redirects.php', |
| 61 |
'option' => '301_redirects' |
| 62 |
] |
| 63 |
]; |
| 64 |
|
| 65 |
public function __construct(&$db_redirection) |
| 66 |
{ |
| 67 |
$this->db_redirection = $db_redirection; |
| 68 |
} |
| 69 |
|
| 70 |
/** |
| 71 |
* Lazy-load the Metasync_Redirection helper used for loop detection. |
| 72 |
*/ |
| 73 |
private function get_redirection_helper() |
| 74 |
{ |
| 75 |
if ($this->redirection_helper === null) { |
| 76 |
require_once dirname(__FILE__) . '/class-metasync-redirection.php'; |
| 77 |
$this->redirection_helper = new Metasync_Redirection($this->db_redirection); |
| 78 |
} |
| 79 |
return $this->redirection_helper; |
| 80 |
} |
| 81 |
|
| 82 |
/** |
| 83 |
* Get list of available plugins for import |
| 84 |
* |
| 85 |
* @return array List of plugins with their status |
| 86 |
*/ |
| 87 |
public function get_available_plugins() |
| 88 |
{ |
| 89 |
global $wpdb; |
| 90 |
$available = []; |
| 91 |
|
| 92 |
foreach (self::SUPPORTED_PLUGINS as $key => $plugin) { |
| 93 |
$status = [ |
| 94 |
'key' => $key, |
| 95 |
'name' => $plugin['name'], |
| 96 |
'installed' => false, |
| 97 |
'has_data' => false, |
| 98 |
'count' => 0 |
| 99 |
]; |
| 100 |
|
| 101 |
# Check if plugin is installed/active |
| 102 |
if (isset($plugin['slug'])) { |
| 103 |
$status['installed'] = is_plugin_active($plugin['slug']) || $this->plugin_exists($plugin['slug']); |
| 104 |
} elseif (isset($plugin['slugs'])) { |
| 105 |
# Check multiple possible slugs (for Yoast) |
| 106 |
foreach ($plugin['slugs'] as $slug) { |
| 107 |
if (is_plugin_active($slug) || $this->plugin_exists($slug)) { |
| 108 |
$status['installed'] = true; |
| 109 |
break; |
| 110 |
} |
| 111 |
} |
| 112 |
} |
| 113 |
|
| 114 |
# Check if data exists |
| 115 |
if (isset($plugin['table'])) { |
| 116 |
$table_name = $wpdb->prefix . $plugin['table']; |
| 117 |
if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") === $table_name) { |
| 118 |
$count = $wpdb->get_var("SELECT COUNT(*) FROM $table_name"); |
| 119 |
if ($count > 0) { |
| 120 |
$status['has_data'] = true; |
| 121 |
$status['count'] = (int)$count; |
| 122 |
} |
| 123 |
} |
| 124 |
} elseif (isset($plugin['option'])) { |
| 125 |
$option_data = get_option($plugin['option'], []); |
| 126 |
if (!empty($option_data)) { |
| 127 |
$status['has_data'] = true; |
| 128 |
$status['count'] = is_array($option_data) ? count($option_data) : 0; |
| 129 |
} |
| 130 |
} elseif (isset($plugin['options'])) { |
| 131 |
# Check multiple options (for Yoast) |
| 132 |
$total_count = 0; |
| 133 |
foreach ($plugin['options'] as $option_name) { |
| 134 |
$option_data = get_option($option_name, []); |
| 135 |
if (!empty($option_data)) { |
| 136 |
$status['has_data'] = true; |
| 137 |
if (is_array($option_data)) { |
| 138 |
$total_count += count($option_data); |
| 139 |
} |
| 140 |
} |
| 141 |
} |
| 142 |
if ($total_count > 0) { |
| 143 |
$status['count'] = $total_count; |
| 144 |
} |
| 145 |
} |
| 146 |
|
| 147 |
$available[] = $status; |
| 148 |
} |
| 149 |
|
| 150 |
return $available; |
| 151 |
} |
| 152 |
|
| 153 |
/** |
| 154 |
* Check if plugin exists (even if not active) |
| 155 |
*/ |
| 156 |
private function plugin_exists($plugin_path) |
| 157 |
{ |
| 158 |
$plugin_file = WP_PLUGIN_DIR . '/' . $plugin_path; |
| 159 |
return file_exists($plugin_file); |
| 160 |
} |
| 161 |
|
| 162 |
/** |
| 163 |
* Import redirections from specified plugin |
| 164 |
* |
| 165 |
* @param string $plugin Plugin key |
| 166 |
* @return array Result with success status and message |
| 167 |
*/ |
| 168 |
public function import_from_plugin($plugin) |
| 169 |
{ |
| 170 |
if (!isset(self::SUPPORTED_PLUGINS[$plugin])) { |
| 171 |
return [ |
| 172 |
'success' => false, |
| 173 |
'message' => 'Unknown plugin specified.', |
| 174 |
'imported' => 0, |
| 175 |
'skipped' => 0 |
| 176 |
]; |
| 177 |
} |
| 178 |
|
| 179 |
$method = "import_from_{$plugin}"; |
| 180 |
if (method_exists($this, $method)) { |
| 181 |
return $this->$method(); |
| 182 |
} |
| 183 |
|
| 184 |
return [ |
| 185 |
'success' => false, |
| 186 |
'message' => 'Import method not implemented.', |
| 187 |
'imported' => 0, |
| 188 |
'skipped' => 0 |
| 189 |
]; |
| 190 |
} |
| 191 |
|
| 192 |
/** |
| 193 |
* Import from Redirection plugin |
| 194 |
*/ |
| 195 |
private function import_from_redirection() |
| 196 |
{ |
| 197 |
global $wpdb; |
| 198 |
$table_name = $wpdb->prefix . 'redirection_items'; |
| 199 |
|
| 200 |
if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") !== $table_name) { |
| 201 |
return [ |
| 202 |
'success' => false, |
| 203 |
'message' => 'Redirection plugin table not found.', |
| 204 |
'imported' => 0, |
| 205 |
'skipped' => 0, |
| 206 |
'loop_skipped' => 0 |
| 207 |
]; |
| 208 |
} |
| 209 |
|
| 210 |
$redirects = $wpdb->get_results(" |
| 211 |
SELECT * FROM $table_name |
| 212 |
WHERE status = 'enabled' |
| 213 |
"); |
| 214 |
|
| 215 |
$imported = 0; |
| 216 |
$skipped = 0; |
| 217 |
$loop_skipped = 0; |
| 218 |
|
| 219 |
foreach ($redirects as $redirect) { |
| 220 |
$http_code = intval($redirect->action_code ?? 301); |
| 221 |
$target_url = $redirect->action_data ?? ''; |
| 222 |
|
| 223 |
# For 410 and 451, target URL can be empty (they don't redirect) |
| 224 |
if (empty($target_url) && !in_array($http_code, [410, 451])) { |
| 225 |
$skipped++; |
| 226 |
continue; |
| 227 |
} |
| 228 |
|
| 229 |
# Check if already exists |
| 230 |
if ($this->redirection_exists($redirect->url)) { |
| 231 |
$skipped++; |
| 232 |
continue; |
| 233 |
} |
| 234 |
|
| 235 |
# Map Redirection plugin format to MetaSync format |
| 236 |
$pattern_type = 'exact'; |
| 237 |
$regex_pattern = null; |
| 238 |
|
| 239 |
# Check if it's a regex |
| 240 |
if (isset($redirect->regex) && $redirect->regex == 1) { |
| 241 |
$pattern_type = 'regex'; |
| 242 |
$regex_pattern = $redirect->url; |
| 243 |
} |
| 244 |
|
| 245 |
$sources = [ |
| 246 |
$redirect->url => $pattern_type |
| 247 |
]; |
| 248 |
|
| 249 |
$args = [ |
| 250 |
'sources_from' => serialize($sources), |
| 251 |
'url_redirect_to' => $target_url, |
| 252 |
'http_code' => $http_code, |
| 253 |
'hits_count' => $redirect->hits ?? 0, |
| 254 |
'status' => 'active', |
| 255 |
'pattern_type' => $pattern_type, |
| 256 |
'regex_pattern' => $regex_pattern, |
| 257 |
'description' => 'Imported from Redirection plugin', |
| 258 |
'created_at' => current_time('mysql'), |
| 259 |
'updated_at' => current_time('mysql') |
| 260 |
]; |
| 261 |
|
| 262 |
if (!$this->is_safe_destination($target_url, $http_code)) { |
| 263 |
$skipped++; |
| 264 |
continue; |
| 265 |
} |
| 266 |
$args['url_redirect_to'] = $target_url; |
| 267 |
|
| 268 |
if (!in_array($http_code, [410, 451]) && $this->get_redirection_helper()->validate_no_loop($redirect->url, $target_url) !== null) { |
| 269 |
$loop_skipped++; |
| 270 |
continue; |
| 271 |
} |
| 272 |
|
| 273 |
if ($this->db_redirection->add($args)) { |
| 274 |
$imported++; |
| 275 |
} |
| 276 |
} |
| 277 |
|
| 278 |
$message = "Successfully imported $imported redirections from Redirection plugin."; |
| 279 |
if ($loop_skipped > 0) { |
| 280 |
$message .= " Skipped $loop_skipped redirect(s) that would have created loops."; |
| 281 |
} |
| 282 |
|
| 283 |
return [ |
| 284 |
'success' => true, |
| 285 |
'message' => $message, |
| 286 |
'imported' => $imported, |
| 287 |
'skipped' => $skipped, |
| 288 |
'loop_skipped' => $loop_skipped |
| 289 |
]; |
| 290 |
} |
| 291 |
|
| 292 |
/** |
| 293 |
* Import from Yoast SEO Premium |
| 294 |
* |
| 295 |
* Yoast stores redirects in multiple options: |
| 296 |
* - wpseo-premium-redirects-base (main storage since v3.1) |
| 297 |
* - wpseo-premium-redirects-export-plain (plain redirects) |
| 298 |
* - wpseo-premium-redirects-export-regex (regex redirects) |
| 299 |
* - wpseo-premium-redirects (legacy plain) |
| 300 |
* - wpseo-premium-redirects-regex (legacy regex) |
| 301 |
*/ |
| 302 |
private function import_from_yoast() |
| 303 |
{ |
| 304 |
$imported = 0; |
| 305 |
$skipped = 0; |
| 306 |
$errors = []; |
| 307 |
$loop_skipped = 0; |
| 308 |
|
| 309 |
# Try to import from base option first (preferred method) |
| 310 |
$base_redirects = get_option('wpseo-premium-redirects-base', []); |
| 311 |
|
| 312 |
if (!empty($base_redirects) && is_array($base_redirects)) { |
| 313 |
foreach ($base_redirects as $redirect) { |
| 314 |
$result = $this->process_yoast_redirect_base($redirect); |
| 315 |
if ($result === true) { |
| 316 |
$imported++; |
| 317 |
} elseif ($result === 'skipped') { |
| 318 |
$skipped++; |
| 319 |
} elseif ($result === 'loop_skipped') { |
| 320 |
$loop_skipped++; |
| 321 |
} else { |
| 322 |
$errors[] = $result; |
| 323 |
} |
| 324 |
} |
| 325 |
} |
| 326 |
|
| 327 |
# If no base redirects, try export options |
| 328 |
if ($imported === 0 && $skipped === 0) { |
| 329 |
# Import plain redirects |
| 330 |
$plain_redirects = get_option('wpseo-premium-redirects-export-plain', []); |
| 331 |
if (!empty($plain_redirects) && is_array($plain_redirects)) { |
| 332 |
foreach ($plain_redirects as $origin => $redirect_data) { |
| 333 |
$result = $this->process_yoast_redirect_export($origin, $redirect_data, 'plain'); |
| 334 |
if ($result === true) { |
| 335 |
$imported++; |
| 336 |
} elseif ($result === 'skipped') { |
| 337 |
$skipped++; |
| 338 |
} elseif ($result === 'loop_skipped') { |
| 339 |
$loop_skipped++; |
| 340 |
} else { |
| 341 |
$errors[] = $result; |
| 342 |
} |
| 343 |
} |
| 344 |
} |
| 345 |
|
| 346 |
# Import regex redirects |
| 347 |
$regex_redirects = get_option('wpseo-premium-redirects-export-regex', []); |
| 348 |
if (!empty($regex_redirects) && is_array($regex_redirects)) { |
| 349 |
foreach ($regex_redirects as $origin => $redirect_data) { |
| 350 |
$result = $this->process_yoast_redirect_export($origin, $redirect_data, 'regex'); |
| 351 |
if ($result === true) { |
| 352 |
$imported++; |
| 353 |
} elseif ($result === 'skipped') { |
| 354 |
$skipped++; |
| 355 |
} elseif ($result === 'loop_skipped') { |
| 356 |
$loop_skipped++; |
| 357 |
} else { |
| 358 |
$errors[] = $result; |
| 359 |
} |
| 360 |
} |
| 361 |
} |
| 362 |
} |
| 363 |
|
| 364 |
# If still no redirects, try legacy options |
| 365 |
if ($imported === 0 && $skipped === 0) { |
| 366 |
# Legacy plain redirects |
| 367 |
$legacy_plain = get_option('wpseo-premium-redirects', []); |
| 368 |
if (!empty($legacy_plain) && is_array($legacy_plain)) { |
| 369 |
foreach ($legacy_plain as $origin => $redirect_data) { |
| 370 |
$result = $this->process_yoast_redirect_export($origin, $redirect_data, 'plain'); |
| 371 |
if ($result === true) { |
| 372 |
$imported++; |
| 373 |
} elseif ($result === 'skipped') { |
| 374 |
$skipped++; |
| 375 |
} elseif ($result === 'loop_skipped') { |
| 376 |
$loop_skipped++; |
| 377 |
} else { |
| 378 |
$errors[] = $result; |
| 379 |
} |
| 380 |
} |
| 381 |
} |
| 382 |
|
| 383 |
# Legacy regex redirects |
| 384 |
$legacy_regex = get_option('wpseo-premium-redirects-regex', []); |
| 385 |
if (!empty($legacy_regex) && is_array($legacy_regex)) { |
| 386 |
foreach ($legacy_regex as $origin => $redirect_data) { |
| 387 |
$result = $this->process_yoast_redirect_export($origin, $redirect_data, 'regex'); |
| 388 |
if ($result === true) { |
| 389 |
$imported++; |
| 390 |
} elseif ($result === 'skipped') { |
| 391 |
$skipped++; |
| 392 |
} elseif ($result === 'loop_skipped') { |
| 393 |
$loop_skipped++; |
| 394 |
} else { |
| 395 |
$errors[] = $result; |
| 396 |
} |
| 397 |
} |
| 398 |
} |
| 399 |
} |
| 400 |
|
| 401 |
# Return results |
| 402 |
if ($imported === 0 && $skipped === 0 && $loop_skipped === 0) { |
| 403 |
return [ |
| 404 |
'success' => false, |
| 405 |
'message' => 'No Yoast redirections found. ' . (!empty($errors) ? implode(' ', array_slice($errors, 0, 2)) : ''), |
| 406 |
'imported' => 0, |
| 407 |
'skipped' => 0, |
| 408 |
'loop_skipped' => 0, |
| 409 |
'errors' => $errors |
| 410 |
]; |
| 411 |
} |
| 412 |
|
| 413 |
$message = $imported > 0 |
| 414 |
? "Successfully imported $imported redirections from Yoast SEO." |
| 415 |
: "All redirections already exist. Skipped $skipped duplicates."; |
| 416 |
if ($loop_skipped > 0) { |
| 417 |
$message .= " Skipped $loop_skipped redirect(s) that would have created loops."; |
| 418 |
} |
| 419 |
|
| 420 |
return [ |
| 421 |
'success' => true, |
| 422 |
'message' => $message, |
| 423 |
'imported' => $imported, |
| 424 |
'skipped' => $skipped, |
| 425 |
'loop_skipped' => $loop_skipped, |
| 426 |
'errors' => $errors |
| 427 |
]; |
| 428 |
} |
| 429 |
|
| 430 |
/** |
| 431 |
* Process a redirect from Yoast base option |
| 432 |
* |
| 433 |
* @param mixed $redirect Redirect data from base option |
| 434 |
* @return bool|string True on success, 'skipped' if exists, error message on failure |
| 435 |
*/ |
| 436 |
private function process_yoast_redirect_base($redirect) |
| 437 |
{ |
| 438 |
try { |
| 439 |
# Base format: ['origin' => string, 'url' => string, 'type' => int, 'format' => string] |
| 440 |
if (!is_array($redirect)) { |
| 441 |
return 'Invalid redirect format'; |
| 442 |
} |
| 443 |
|
| 444 |
$origin = isset($redirect['origin']) ? trim($redirect['origin']) : ''; |
| 445 |
$target = isset($redirect['url']) ? trim($redirect['url']) : ''; |
| 446 |
$type = isset($redirect['type']) ? $redirect['type'] : 301; |
| 447 |
$format = isset($redirect['format']) ? $redirect['format'] : 'plain'; |
| 448 |
|
| 449 |
# Validate origin |
| 450 |
if (empty($origin)) { |
| 451 |
return 'Empty origin URL'; |
| 452 |
} |
| 453 |
|
| 454 |
# For 410 and 451, target URL can be empty (they don't redirect) |
| 455 |
if (empty($target) && !in_array($type, [410, 451])) { |
| 456 |
return 'Empty target URL for non-410/451 redirect'; |
| 457 |
} |
| 458 |
|
| 459 |
# Check if already exists |
| 460 |
if ($this->redirection_exists($origin)) { |
| 461 |
return 'skipped'; |
| 462 |
} |
| 463 |
|
| 464 |
# Determine pattern type |
| 465 |
$pattern_type = ($format === 'regex') ? 'regex' : 'exact'; |
| 466 |
$regex_pattern = ($format === 'regex') ? $origin : null; |
| 467 |
|
| 468 |
$sources = [ |
| 469 |
$origin => $pattern_type |
| 470 |
]; |
| 471 |
|
| 472 |
$args = [ |
| 473 |
'sources_from' => serialize($sources), |
| 474 |
'url_redirect_to' => $target, |
| 475 |
'http_code' => intval($type), |
| 476 |
'hits_count' => 0, |
| 477 |
'status' => 'active', |
| 478 |
'pattern_type' => $pattern_type, |
| 479 |
'regex_pattern' => $regex_pattern, |
| 480 |
'description' => 'Imported from Yoast SEO', |
| 481 |
'created_at' => current_time('mysql'), |
| 482 |
'updated_at' => current_time('mysql') |
| 483 |
]; |
| 484 |
|
| 485 |
if (!$this->is_safe_destination($target, intval($type))) { |
| 486 |
return 'skipped'; |
| 487 |
} |
| 488 |
$args['url_redirect_to'] = $target; |
| 489 |
|
| 490 |
if (!in_array(intval($type), [410, 451]) && $this->get_redirection_helper()->validate_no_loop($origin, $target) !== null) { |
| 491 |
return 'loop_skipped'; |
| 492 |
} |
| 493 |
|
| 494 |
if ($this->db_redirection->add($args)) { |
| 495 |
return true; |
| 496 |
} |
| 497 |
|
| 498 |
return 'Failed to insert redirect'; |
| 499 |
|
| 500 |
} catch (Exception $e) { |
| 501 |
return 'Exception: ' . $e->getMessage(); |
| 502 |
} |
| 503 |
} |
| 504 |
|
| 505 |
/** |
| 506 |
* Process a redirect from Yoast export options |
| 507 |
* |
| 508 |
* @param string $origin Source URL |
| 509 |
* @param mixed $redirect_data Redirect data from export option |
| 510 |
* @param string $format Format type ('plain' or 'regex') |
| 511 |
* @return bool|string True on success, 'skipped' if exists, error message on failure |
| 512 |
*/ |
| 513 |
private function process_yoast_redirect_export($origin, $redirect_data, $format) |
| 514 |
{ |
| 515 |
try { |
| 516 |
$origin = trim($origin); |
| 517 |
|
| 518 |
# Export format: ['url' => string, 'type' => int] |
| 519 |
if (is_array($redirect_data)) { |
| 520 |
$target = isset($redirect_data['url']) ? trim($redirect_data['url']) : ''; |
| 521 |
$type = isset($redirect_data['type']) ? $redirect_data['type'] : 301; |
| 522 |
} elseif (is_string($redirect_data)) { |
| 523 |
# Simple string format (target URL only) |
| 524 |
$target = trim($redirect_data); |
| 525 |
$type = 301; |
| 526 |
} else { |
| 527 |
return 'Invalid redirect data format'; |
| 528 |
} |
| 529 |
|
| 530 |
# Validate origin |
| 531 |
if (empty($origin)) { |
| 532 |
return 'Empty origin URL'; |
| 533 |
} |
| 534 |
|
| 535 |
# For 410 and 451, target URL can be empty (they don't redirect) |
| 536 |
if (empty($target) && !in_array($type, [410, 451])) { |
| 537 |
return 'Empty target URL for non-410/451 redirect'; |
| 538 |
} |
| 539 |
|
| 540 |
# Check if already exists |
| 541 |
if ($this->redirection_exists($origin)) { |
| 542 |
return 'skipped'; |
| 543 |
} |
| 544 |
|
| 545 |
# Determine pattern type |
| 546 |
$pattern_type = ($format === 'regex') ? 'regex' : 'exact'; |
| 547 |
$regex_pattern = ($format === 'regex') ? $origin : null; |
| 548 |
|
| 549 |
$sources = [ |
| 550 |
$origin => $pattern_type |
| 551 |
]; |
| 552 |
|
| 553 |
$args = [ |
| 554 |
'sources_from' => serialize($sources), |
| 555 |
'url_redirect_to' => $target, |
| 556 |
'http_code' => intval($type), |
| 557 |
'hits_count' => 0, |
| 558 |
'status' => 'active', |
| 559 |
'pattern_type' => $pattern_type, |
| 560 |
'regex_pattern' => $regex_pattern, |
| 561 |
'description' => 'Imported from Yoast SEO', |
| 562 |
'created_at' => current_time('mysql'), |
| 563 |
'updated_at' => current_time('mysql') |
| 564 |
]; |
| 565 |
|
| 566 |
if (!$this->is_safe_destination($target, intval($type))) { |
| 567 |
return 'skipped'; |
| 568 |
} |
| 569 |
$args['url_redirect_to'] = $target; |
| 570 |
|
| 571 |
if (!in_array(intval($type), [410, 451]) && $this->get_redirection_helper()->validate_no_loop($origin, $target) !== null) { |
| 572 |
return 'loop_skipped'; |
| 573 |
} |
| 574 |
|
| 575 |
if ($this->db_redirection->add($args)) { |
| 576 |
return true; |
| 577 |
} |
| 578 |
|
| 579 |
return 'Failed to insert redirect'; |
| 580 |
|
| 581 |
} catch (Exception $e) { |
| 582 |
return 'Exception: ' . $e->getMessage(); |
| 583 |
} |
| 584 |
} |
| 585 |
|
| 586 |
/** |
| 587 |
* Import from Rank Math |
| 588 |
*/ |
| 589 |
private function import_from_rankmath() |
| 590 |
{ |
| 591 |
global $wpdb; |
| 592 |
$table_name = $wpdb->prefix . 'rank_math_redirections'; |
| 593 |
|
| 594 |
if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") !== $table_name) { |
| 595 |
return [ |
| 596 |
'success' => false, |
| 597 |
'message' => 'Rank Math redirections table not found.', |
| 598 |
'imported' => 0, |
| 599 |
'skipped' => 0, |
| 600 |
'loop_skipped' => 0, |
| 601 |
'errors' => ['Table not found: ' . $table_name] |
| 602 |
]; |
| 603 |
} |
| 604 |
|
| 605 |
# Get all redirections (not just active ones, Rank Math uses different status field) |
| 606 |
$redirects = $wpdb->get_results("SELECT * FROM $table_name"); |
| 607 |
|
| 608 |
if (empty($redirects)) { |
| 609 |
return [ |
| 610 |
'success' => false, |
| 611 |
'message' => 'No redirections found in Rank Math.', |
| 612 |
'imported' => 0, |
| 613 |
'skipped' => 0, |
| 614 |
'loop_skipped' => 0, |
| 615 |
'errors' => [] |
| 616 |
]; |
| 617 |
} |
| 618 |
|
| 619 |
$imported = 0; |
| 620 |
$skipped = 0; |
| 621 |
$errors = []; |
| 622 |
$loop_skipped = 0; |
| 623 |
|
| 624 |
foreach ($redirects as $redirect) { |
| 625 |
try { |
| 626 |
# Rank Math uses 'sources' field - it's a serialized PHP array |
| 627 |
$source_url = ''; |
| 628 |
$comparison_type = 'exact'; // Default to exact |
| 629 |
|
| 630 |
if (isset($redirect->sources)) { |
| 631 |
# First, try to unserialize (Rank Math format) |
| 632 |
$unserialized = @maybe_unserialize($redirect->sources); |
| 633 |
|
| 634 |
if (is_array($unserialized) && !empty($unserialized)) { |
| 635 |
# Rank Math format: array of arrays with 'pattern', 'comparison', 'ignore' keys |
| 636 |
$first_source = reset($unserialized); |
| 637 |
|
| 638 |
if (is_array($first_source) && isset($first_source['pattern'])) { |
| 639 |
# Extract the actual URL pattern |
| 640 |
$source_url = $first_source['pattern']; |
| 641 |
|
| 642 |
# Extract comparison type (exact, regex, contains, starts, ends) |
| 643 |
if (isset($first_source['comparison'])) { |
| 644 |
$comparison_type = $first_source['comparison']; |
| 645 |
} |
| 646 |
} elseif (is_string($first_source)) { |
| 647 |
# Simple string format |
| 648 |
$source_url = $first_source; |
| 649 |
} |
| 650 |
} elseif (is_string($unserialized)) { |
| 651 |
# Direct string value |
| 652 |
$source_url = $unserialized; |
| 653 |
} else { |
| 654 |
# Try as JSON |
| 655 |
$parsed = json_decode($redirect->sources, true); |
| 656 |
if (json_last_error() === JSON_ERROR_NONE && is_array($parsed)) { |
| 657 |
$source_url = is_array($parsed) ? reset($parsed) : $parsed; |
| 658 |
} else { |
| 659 |
# Last resort - use as-is |
| 660 |
$source_url = $redirect->sources; |
| 661 |
} |
| 662 |
} |
| 663 |
} elseif (isset($redirect->url_from)) { |
| 664 |
$source_url = $redirect->url_from; |
| 665 |
} |
| 666 |
|
| 667 |
# Clean up the source URL |
| 668 |
$source_url = trim($source_url); |
| 669 |
|
| 670 |
if (empty($source_url)) { |
| 671 |
$errors[] = 'Empty source URL in redirect ID: ' . ($redirect->id ?? 'unknown'); |
| 672 |
$skipped++; |
| 673 |
continue; |
| 674 |
} |
| 675 |
|
| 676 |
# Check if already exists |
| 677 |
if ($this->redirection_exists($source_url)) { |
| 678 |
$skipped++; |
| 679 |
continue; |
| 680 |
} |
| 681 |
|
| 682 |
# Map Rank Math comparison types to MetaSync pattern types |
| 683 |
# Rank Math: exact, regex, contains, starts, ends |
| 684 |
# MetaSync: exact, regex, contain, start, end |
| 685 |
$pattern_type_map = [ |
| 686 |
'exact' => 'exact', |
| 687 |
'regex' => 'regex', |
| 688 |
'contains' => 'contain', |
| 689 |
'starts' => 'start', |
| 690 |
'ends' => 'end' |
| 691 |
]; |
| 692 |
|
| 693 |
$pattern_type = isset($pattern_type_map[$comparison_type]) |
| 694 |
? $pattern_type_map[$comparison_type] |
| 695 |
: 'exact'; |
| 696 |
|
| 697 |
# For regex patterns, store the pattern |
| 698 |
$regex_pattern = ($pattern_type === 'regex') ? $source_url : null; |
| 699 |
|
| 700 |
$sources = [ |
| 701 |
$source_url => $pattern_type |
| 702 |
]; |
| 703 |
|
| 704 |
$target_url = $redirect->url_to ?? ''; |
| 705 |
$http_code = intval($redirect->header_code ?? 301); |
| 706 |
|
| 707 |
# For 410 and 451, target URL can be empty (they don't redirect) |
| 708 |
if (empty($target_url) && !in_array($http_code, [410, 451])) { |
| 709 |
$errors[] = 'Empty target URL for source: ' . $source_url; |
| 710 |
$skipped++; |
| 711 |
continue; |
| 712 |
} |
| 713 |
|
| 714 |
$args = [ |
| 715 |
'sources_from' => serialize($sources), |
| 716 |
'url_redirect_to' => $target_url, |
| 717 |
'http_code' => $http_code, |
| 718 |
'hits_count' => $redirect->hits ?? 0, |
| 719 |
'status' => 'active', |
| 720 |
'pattern_type' => $pattern_type, |
| 721 |
'regex_pattern' => $regex_pattern, |
| 722 |
'description' => 'Imported from Rank Math', |
| 723 |
'created_at' => current_time('mysql'), |
| 724 |
'updated_at' => current_time('mysql') |
| 725 |
]; |
| 726 |
|
| 727 |
if (!$this->is_safe_destination($target_url, $http_code)) { |
| 728 |
$skipped++; |
| 729 |
continue; |
| 730 |
} |
| 731 |
$args['url_redirect_to'] = $target_url; |
| 732 |
|
| 733 |
if (!in_array($http_code, [410, 451]) && $this->get_redirection_helper()->validate_no_loop($source_url, $target_url) !== null) { |
| 734 |
$loop_skipped++; |
| 735 |
continue; |
| 736 |
} |
| 737 |
|
| 738 |
$result = $this->db_redirection->add($args); |
| 739 |
|
| 740 |
if ($result !== false && $result > 0) { |
| 741 |
$imported++; |
| 742 |
} else { |
| 743 |
$errors[] = 'Failed to insert redirect: ' . $source_url . ' -> ' . $target_url; |
| 744 |
if ($wpdb->last_error) { |
| 745 |
$errors[] = 'DB Error: ' . $wpdb->last_error; |
| 746 |
} |
| 747 |
} |
| 748 |
|
| 749 |
} catch (Exception $e) { |
| 750 |
$errors[] = 'Exception: ' . $e->getMessage(); |
| 751 |
$skipped++; |
| 752 |
} |
| 753 |
} |
| 754 |
|
| 755 |
# Return success if we processed redirections (even if all skipped) |
| 756 |
$has_results = ($imported + $skipped + $loop_skipped) > 0; |
| 757 |
|
| 758 |
$message = $imported > 0 |
| 759 |
? "Successfully imported $imported redirections from Rank Math." |
| 760 |
: ($skipped > 0 |
| 761 |
? "All redirections already exist. Skipped $skipped duplicates." |
| 762 |
: "No redirections found to import. " . (count($errors) > 0 ? implode(' ', array_slice($errors, 0, 2)) : '')); |
| 763 |
if ($loop_skipped > 0) { |
| 764 |
$message .= " Skipped $loop_skipped redirect(s) that would have created loops."; |
| 765 |
} |
| 766 |
|
| 767 |
return [ |
| 768 |
'success' => $has_results, |
| 769 |
'message' => $message, |
| 770 |
'imported' => $imported, |
| 771 |
'skipped' => $skipped, |
| 772 |
'loop_skipped' => $loop_skipped, |
| 773 |
'errors' => $errors |
| 774 |
]; |
| 775 |
} |
| 776 |
|
| 777 |
/** |
| 778 |
* Import from All in One SEO |
| 779 |
*/ |
| 780 |
private function import_from_aioseo() |
| 781 |
{ |
| 782 |
global $wpdb; |
| 783 |
$table_name = $wpdb->prefix . 'aioseo_redirects'; |
| 784 |
|
| 785 |
if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") !== $table_name) { |
| 786 |
return [ |
| 787 |
'success' => false, |
| 788 |
'message' => 'All in One SEO redirects table not found.', |
| 789 |
'imported' => 0, |
| 790 |
'skipped' => 0, |
| 791 |
'loop_skipped' => 0 |
| 792 |
]; |
| 793 |
} |
| 794 |
|
| 795 |
$redirects = $wpdb->get_results(" |
| 796 |
SELECT * FROM $table_name |
| 797 |
WHERE enabled = 1 |
| 798 |
"); |
| 799 |
|
| 800 |
$imported = 0; |
| 801 |
$skipped = 0; |
| 802 |
$loop_skipped = 0; |
| 803 |
|
| 804 |
foreach ($redirects as $redirect) { |
| 805 |
$http_code = intval($redirect->redirect_code ?? 301); |
| 806 |
$target_url = $redirect->target_url ?? ''; |
| 807 |
|
| 808 |
# For 410 and 451, target URL can be empty (they don't redirect) |
| 809 |
if (empty($target_url) && !in_array($http_code, [410, 451])) { |
| 810 |
$skipped++; |
| 811 |
continue; |
| 812 |
} |
| 813 |
|
| 814 |
# Check if already exists |
| 815 |
if ($this->redirection_exists($redirect->source_url)) { |
| 816 |
$skipped++; |
| 817 |
continue; |
| 818 |
} |
| 819 |
|
| 820 |
# Determine pattern type |
| 821 |
$pattern_type = 'exact'; |
| 822 |
$regex_pattern = null; |
| 823 |
|
| 824 |
if (isset($redirect->regex) && $redirect->regex == 1) { |
| 825 |
$pattern_type = 'regex'; |
| 826 |
$regex_pattern = $redirect->source_url; |
| 827 |
} |
| 828 |
|
| 829 |
$sources = [ |
| 830 |
$redirect->source_url => $pattern_type |
| 831 |
]; |
| 832 |
|
| 833 |
$args = [ |
| 834 |
'sources_from' => serialize($sources), |
| 835 |
'url_redirect_to' => $target_url, |
| 836 |
'http_code' => $http_code, |
| 837 |
'hits_count' => $redirect->hits ?? 0, |
| 838 |
'status' => 'active', |
| 839 |
'pattern_type' => $pattern_type, |
| 840 |
'regex_pattern' => $regex_pattern, |
| 841 |
'description' => 'Imported from All in One SEO', |
| 842 |
'created_at' => current_time('mysql'), |
| 843 |
'updated_at' => current_time('mysql') |
| 844 |
]; |
| 845 |
|
| 846 |
if (!$this->is_safe_destination($target_url, $http_code)) { |
| 847 |
$skipped++; |
| 848 |
continue; |
| 849 |
} |
| 850 |
$args['url_redirect_to'] = $target_url; |
| 851 |
|
| 852 |
if (!in_array($http_code, [410, 451]) && $this->get_redirection_helper()->validate_no_loop($redirect->source_url, $target_url) !== null) { |
| 853 |
$loop_skipped++; |
| 854 |
continue; |
| 855 |
} |
| 856 |
|
| 857 |
if ($this->db_redirection->add($args)) { |
| 858 |
$imported++; |
| 859 |
} |
| 860 |
} |
| 861 |
|
| 862 |
$message = "Successfully imported $imported redirections from All in One SEO."; |
| 863 |
if ($loop_skipped > 0) { |
| 864 |
$message .= " Skipped $loop_skipped redirect(s) that would have created loops."; |
| 865 |
} |
| 866 |
|
| 867 |
return [ |
| 868 |
'success' => true, |
| 869 |
'message' => $message, |
| 870 |
'imported' => $imported, |
| 871 |
'skipped' => $skipped, |
| 872 |
'loop_skipped' => $loop_skipped |
| 873 |
]; |
| 874 |
} |
| 875 |
|
| 876 |
/** |
| 877 |
* Import from Simple 301 Redirects |
| 878 |
*/ |
| 879 |
private function import_from_simple301() |
| 880 |
{ |
| 881 |
# Simple 301 Redirects stores in options |
| 882 |
$redirects = get_option('301_redirects', []); |
| 883 |
|
| 884 |
if (empty($redirects)) { |
| 885 |
return [ |
| 886 |
'success' => false, |
| 887 |
'message' => 'No Simple 301 Redirects found.', |
| 888 |
'imported' => 0, |
| 889 |
'skipped' => 0, |
| 890 |
'loop_skipped' => 0 |
| 891 |
]; |
| 892 |
} |
| 893 |
|
| 894 |
$imported = 0; |
| 895 |
$skipped = 0; |
| 896 |
$loop_skipped = 0; |
| 897 |
|
| 898 |
foreach ($redirects as $old_url => $new_url) { |
| 899 |
# Skip empty entries |
| 900 |
if (empty($old_url) || empty($new_url)) { |
| 901 |
$skipped++; |
| 902 |
continue; |
| 903 |
} |
| 904 |
|
| 905 |
# Check if already exists |
| 906 |
if ($this->redirection_exists($old_url)) { |
| 907 |
$skipped++; |
| 908 |
continue; |
| 909 |
} |
| 910 |
|
| 911 |
$sources = [ |
| 912 |
$old_url => 'exact' |
| 913 |
]; |
| 914 |
|
| 915 |
$args = [ |
| 916 |
'sources_from' => serialize($sources), |
| 917 |
'url_redirect_to' => $new_url, |
| 918 |
'http_code' => 301, |
| 919 |
'hits_count' => 0, |
| 920 |
'status' => 'active', |
| 921 |
'pattern_type' => 'exact', |
| 922 |
'regex_pattern' => null, |
| 923 |
'description' => 'Imported from Simple 301 Redirects', |
| 924 |
'created_at' => current_time('mysql'), |
| 925 |
'updated_at' => current_time('mysql') |
| 926 |
]; |
| 927 |
|
| 928 |
if (!$this->is_safe_destination($new_url, 301)) { |
| 929 |
$skipped++; |
| 930 |
continue; |
| 931 |
} |
| 932 |
$args['url_redirect_to'] = $new_url; |
| 933 |
|
| 934 |
if ($this->get_redirection_helper()->validate_no_loop($old_url, $new_url) !== null) { |
| 935 |
$loop_skipped++; |
| 936 |
continue; |
| 937 |
} |
| 938 |
|
| 939 |
if ($this->db_redirection->add($args)) { |
| 940 |
$imported++; |
| 941 |
} |
| 942 |
} |
| 943 |
|
| 944 |
$message = "Successfully imported $imported redirections from Simple 301 Redirects."; |
| 945 |
if ($loop_skipped > 0) { |
| 946 |
$message .= " Skipped $loop_skipped redirect(s) that would have created loops."; |
| 947 |
} |
| 948 |
|
| 949 |
return [ |
| 950 |
'success' => true, |
| 951 |
'message' => $message, |
| 952 |
'imported' => $imported, |
| 953 |
'skipped' => $skipped, |
| 954 |
'loop_skipped' => $loop_skipped |
| 955 |
]; |
| 956 |
} |
| 957 |
|
| 958 |
/** |
| 959 |
* Reject off-site destination URLs at import time so attacker-controlled |
| 960 |
* exports cannot smuggle external redirects in via plugin migration. |
| 961 |
* |
| 962 |
* 410 and 451 entries have no destination, so they are always safe. |
| 963 |
* |
| 964 |
* @param string $url Destination URL from the source plugin. |
| 965 |
* @param int $http_code HTTP status the redirect will serve. |
| 966 |
* @return bool True when the destination is empty-by-design or resolves on-site. |
| 967 |
*/ |
| 968 |
private function is_safe_destination(&$url, $http_code) |
| 969 |
{ |
| 970 |
if (in_array(intval($http_code), [410, 451], true)) { |
| 971 |
$url = ''; |
| 972 |
return true; |
| 973 |
} |
| 974 |
|
| 975 |
$url = (string) $url; |
| 976 |
if ($url === '') { |
| 977 |
return false; |
| 978 |
} |
| 979 |
|
| 980 |
if (get_option('metasync_allow_external_redirects', 0)) { |
| 981 |
return true; |
| 982 |
} |
| 983 |
|
| 984 |
return wp_validate_redirect($url, '') !== ''; |
| 985 |
} |
| 986 |
|
| 987 |
/** |
| 988 |
* Check if a redirection already exists |
| 989 |
* |
| 990 |
* @param string $source_url Source URL to check |
| 991 |
* @return bool True if exists, false otherwise |
| 992 |
*/ |
| 993 |
private function redirection_exists($source_url) |
| 994 |
{ |
| 995 |
global $wpdb; |
| 996 |
$table_name = $wpdb->prefix . Metasync_Redirection_Database::$table_name; |
| 997 |
|
| 998 |
$count = $wpdb->get_var($wpdb->prepare( |
| 999 |
"SELECT COUNT(*) FROM $table_name WHERE sources_from LIKE %s", |
| 1000 |
'%' . $wpdb->esc_like($source_url) . '%' |
| 1001 |
)); |
| 1002 |
|
| 1003 |
return $count > 0; |
| 1004 |
} |
| 1005 |
|
| 1006 |
/** |
| 1007 |
* Get import statistics |
| 1008 |
* |
| 1009 |
* @return array Statistics about imports |
| 1010 |
*/ |
| 1011 |
public function get_import_stats() |
| 1012 |
{ |
| 1013 |
global $wpdb; |
| 1014 |
$table_name = $wpdb->prefix . Metasync_Redirection_Database::$table_name; |
| 1015 |
|
| 1016 |
$stats = [ |
| 1017 |
'total_redirections' => 0, |
| 1018 |
'imported_redirections' => 0 |
| 1019 |
]; |
| 1020 |
|
| 1021 |
$stats['total_redirections'] = $wpdb->get_var("SELECT COUNT(*) FROM $table_name"); |
| 1022 |
|
| 1023 |
$stats['imported_redirections'] = $wpdb->get_var($wpdb->prepare( |
| 1024 |
"SELECT COUNT(*) FROM $table_name WHERE description LIKE %s", |
| 1025 |
'Imported from%' |
| 1026 |
)); |
| 1027 |
|
| 1028 |
return $stats; |
| 1029 |
} |
| 1030 |
} |
| 1031 |
|