PluginProbe
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress / trunk
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress vtrunk
0.9.4 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 0.8.5 0.8.4 0.8.2 0.8.1 0.7.9 0.8.0 0.7.7 0.7.8 0.7.6 0.7.5 0.7.4 0.7.3 0.7.2 0.7.1 0.7.0 0.6.5 All 88 releases
seo-engine / classes / modules / redirects.php

redirects.php in SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress trunk, at classes/modules/redirects.php

809 lines 25.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 class Meow_MWSEO_Modules_Redirects
4 {
5 private $core = null;
6 private $redirects_table = null;
7 private $log_404_table = null;
8 private $home_path = null;
9 private $deferred_hits = array(); // [rule_id => count] flushed on shutdown
10
11 public function __construct( $core ) {
12 $this->core = $core;
13 global $wpdb;
14 $this->redirects_table = $wpdb->prefix . MWSEO_PREFIX . '_redirects';
15 $this->log_404_table = $wpdb->prefix . MWSEO_PREFIX . '_404_log';
16 $this->init();
17 }
18
19 public function init() {
20 // Module is opt-in: bail entirely when disabled so we don't create tables
21 // or schedule cron on sites that aren't using it.
22 if ( !$this->core->get_option( 'redirects_enabled', false ) ) {
23 // Clean up the cron if the module was previously enabled.
24 $ts = wp_next_scheduled( 'mwseo_prune_404_log' );
25 if ( $ts ) { wp_unschedule_event( $ts, 'mwseo_prune_404_log' ); }
26 return;
27 }
28
29 $this->maybe_create_redirects_table();
30 $this->maybe_create_404_log_table();
31
32 add_action( 'template_redirect', array( $this, 'handle_redirect_match' ), 1 );
33
34 if ( $this->core->get_option( 'redirects_track_404', true ) ) {
35 add_action( 'template_redirect', array( $this, 'maybe_log_404' ), 99 );
36 }
37
38 if ( $this->core->get_option( 'redirects_auto_slug', false ) ) {
39 add_action( 'post_updated', array( $this, 'maybe_auto_redirect_on_slug_change' ), 10, 3 );
40 }
41
42 // Daily cron to prune old 404 entries
43 add_action( 'mwseo_prune_404_log', array( $this, 'prune_old_404s' ) );
44 if ( !wp_next_scheduled( 'mwseo_prune_404_log' ) ) {
45 wp_schedule_event( time() + 3600, 'daily', 'mwseo_prune_404_log' );
46 }
47 }
48
49 #region DB Tables
50
51 private function maybe_create_redirects_table() {
52 global $wpdb;
53
54 $table_exists = $wpdb->get_var( "SHOW TABLES LIKE '$this->redirects_table'" ) === $this->redirects_table;
55 if ( $table_exists ) { return; }
56
57 $charset_collate = $wpdb->get_charset_collate();
58
59 $sql = "CREATE TABLE $this->redirects_table (
60 id bigint(20) NOT NULL AUTO_INCREMENT,
61 source_url varchar(500) NOT NULL,
62 source_hash char(32) NOT NULL,
63 match_type varchar(10) NOT NULL DEFAULT 'exact',
64 target_url varchar(500) NOT NULL DEFAULT '',
65 status_code smallint(5) NOT NULL DEFAULT 301,
66 enabled tinyint(1) NOT NULL DEFAULT 1,
67 hits int(10) unsigned NOT NULL DEFAULT 0,
68 last_hit datetime DEFAULT NULL,
69 created_at datetime DEFAULT CURRENT_TIMESTAMP,
70 updated_at datetime DEFAULT CURRENT_TIMESTAMP,
71 notes varchar(255) DEFAULT NULL,
72 PRIMARY KEY (id),
73 KEY source_hash (source_hash),
74 KEY enabled (enabled),
75 KEY match_type (match_type)
76 ) $charset_collate;";
77
78 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
79 dbDelta( $sql );
80 }
81
82 private function maybe_create_404_log_table() {
83 global $wpdb;
84
85 $table_exists = $wpdb->get_var( "SHOW TABLES LIKE '$this->log_404_table'" ) === $this->log_404_table;
86 if ( $table_exists ) { return; }
87
88 $charset_collate = $wpdb->get_charset_collate();
89
90 $sql = "CREATE TABLE $this->log_404_table (
91 id bigint(20) NOT NULL AUTO_INCREMENT,
92 url varchar(500) NOT NULL,
93 url_hash char(32) NOT NULL,
94 hits int(10) unsigned NOT NULL DEFAULT 1,
95 first_hit datetime DEFAULT CURRENT_TIMESTAMP,
96 last_hit datetime DEFAULT CURRENT_TIMESTAMP,
97 last_referer varchar(500) DEFAULT NULL,
98 last_user_agent varchar(255) DEFAULT NULL,
99 ignored tinyint(1) NOT NULL DEFAULT 0,
100 PRIMARY KEY (id),
101 UNIQUE KEY url_hash (url_hash),
102 KEY last_hit (last_hit),
103 KEY ignored (ignored)
104 ) $charset_collate;";
105
106 require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
107 dbDelta( $sql );
108 }
109
110 #endregion
111
112 #region Hot path: redirect match
113
114 public function handle_redirect_match() {
115 // Skip admin, REST, AJAX, login
116 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
117 return;
118 }
119
120 $path = $this->get_request_path();
121 if ( empty( $path ) ) { return; }
122
123 // Skip wp-admin / wp-login / similar system paths
124 if ( preg_match( '#^/?(wp-admin|wp-login\.php|wp-cron\.php|xmlrpc\.php|wp-json)#', $path ) ) {
125 return;
126 }
127
128 $normalized = $this->normalize_path( $path );
129 $hash = md5( $normalized );
130
131 // Try exact match (cached)
132 $rule = wp_cache_get( $hash, 'mwseo_redirects' );
133 if ( $rule === false ) {
134 global $wpdb;
135 $rule = $wpdb->get_row( $wpdb->prepare(
136 "SELECT id, target_url, status_code FROM $this->redirects_table
137 WHERE source_hash = %s AND match_type = 'exact' AND enabled = 1
138 LIMIT 1",
139 $hash
140 ), ARRAY_A );
141 // Cache result (positive or negative) for 5 minutes
142 wp_cache_set( $hash, $rule ?: 'none', 'mwseo_redirects', 300 );
143 }
144
145 if ( is_array( $rule ) && !empty( $rule ) ) {
146 $this->execute_redirect( $rule, $normalized );
147 return;
148 }
149
150 // Skip regex scan for static assets — never redirected anyway
151 if ( preg_match( '/\.(jpg|jpeg|png|gif|webp|svg|ico|css|js|map|woff2?|ttf|eot|mp4|mp3|pdf|zip|xml)$/i', $normalized ) ) {
152 return;
153 }
154
155 // Regex rules (Pro only) — load all enabled regex rules from cache, scan in PHP
156 $regex_rules = wp_cache_get( 'all_regex', 'mwseo_redirects' );
157 if ( $regex_rules === false ) {
158 global $wpdb;
159 $regex_rules = $wpdb->get_results(
160 "SELECT id, source_url, target_url, status_code FROM $this->redirects_table
161 WHERE match_type = 'regex' AND enabled = 1",
162 ARRAY_A
163 );
164 wp_cache_set( 'all_regex', $regex_rules ?: array(), 'mwseo_redirects', 300 );
165 }
166
167 if ( !empty( $regex_rules ) && class_exists( 'MeowPro_MWSEO_Core' ) ) {
168 foreach ( $regex_rules as $r ) {
169 $pattern = '#' . str_replace( '#', '\\#', $r['source_url'] ) . '#';
170 $result = @preg_match( $pattern, $normalized );
171 if ( $result === 1 ) {
172 $target = @preg_replace( $pattern, $r['target_url'], $normalized );
173 $rule = array(
174 'id' => $r['id'],
175 'target_url' => $target,
176 'status_code' => $r['status_code'],
177 );
178 $this->execute_redirect( $rule, $normalized );
179 return;
180 }
181 }
182 }
183 }
184
185 private function execute_redirect( $rule, $source_path ) {
186 $target = $rule['target_url'];
187 $status = intval( $rule['status_code'] );
188
189 // 410 Gone — set status, do not redirect
190 if ( $status === 410 ) {
191 $this->queue_hit_increment( $rule['id'] );
192 status_header( 410 );
193 nocache_headers();
194 return;
195 }
196
197 // Resolve relative target to absolute URL
198 if ( $target && $target[0] === '/' ) {
199 $target = home_url( $target );
200 }
201 elseif ( !preg_match( '#^https?://#i', $target ) ) {
202 $target = home_url( '/' . ltrim( $target, '/' ) );
203 }
204
205 $this->queue_hit_increment( $rule['id'] );
206
207 wp_redirect( $target, $status, 'SEO Engine' );
208 exit;
209 }
210
211 private function queue_hit_increment( $rule_id ) {
212 // Defer the UPDATE to shutdown so it doesn't block the redirect response
213 if ( empty( $this->deferred_hits ) ) {
214 add_action( 'shutdown', array( $this, 'flush_deferred_hits' ), 99 );
215 }
216 $rule_id = intval( $rule_id );
217 if ( !isset( $this->deferred_hits[ $rule_id ] ) ) {
218 $this->deferred_hits[ $rule_id ] = 0;
219 }
220 $this->deferred_hits[ $rule_id ]++;
221 }
222
223 public function flush_deferred_hits() {
224 if ( empty( $this->deferred_hits ) ) { return; }
225 global $wpdb;
226 foreach ( $this->deferred_hits as $rule_id => $count ) {
227 $wpdb->query( $wpdb->prepare(
228 "UPDATE $this->redirects_table SET hits = hits + %d, last_hit = NOW() WHERE id = %d",
229 $count, $rule_id
230 ) );
231 }
232 $this->deferred_hits = array();
233 }
234
235 #endregion
236
237 #region 404 logging
238
239 public function maybe_log_404() {
240 if ( !is_404() ) { return; }
241
242 // Skip bots if configured
243 if ( $this->core->get_option( 'redirects_skip_bots', true ) && $this->is_crawler() ) {
244 return;
245 }
246
247 $path = $this->get_request_path();
248 if ( empty( $path ) ) { return; }
249
250 $normalized = $this->normalize_path( $path );
251
252 // Skip media / asset extensions — broken images and files are noise
253 // for SEO, and would otherwise dominate the 404 log.
254 if ( $this->is_asset_path( $normalized ) ) {
255 return;
256 }
257
258 // Skip excluded patterns
259 $excludes = $this->get_404_exclude_patterns();
260 foreach ( $excludes as $pattern ) {
261 if ( $this->path_matches_wildcard( $normalized, $pattern ) ) {
262 return;
263 }
264 }
265
266 $hash = md5( $normalized );
267 $referer = isset( $_SERVER['HTTP_REFERER'] ) ? mb_substr( $_SERVER['HTTP_REFERER'], 0, 500 ) : null;
268 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? mb_substr( $_SERVER['HTTP_USER_AGENT'], 0, 255 ) : null;
269
270 global $wpdb;
271 $wpdb->query( $wpdb->prepare(
272 "INSERT INTO $this->log_404_table (url, url_hash, hits, first_hit, last_hit, last_referer, last_user_agent)
273 VALUES (%s, %s, 1, NOW(), NOW(), %s, %s)
274 ON DUPLICATE KEY UPDATE
275 hits = hits + 1,
276 last_hit = NOW(),
277 last_referer = VALUES(last_referer),
278 last_user_agent = VALUES(last_user_agent)",
279 mb_substr( $normalized, 0, 500 ),
280 $hash,
281 $referer,
282 $user_agent
283 ) );
284 }
285
286 private function get_404_exclude_patterns() {
287 $raw = $this->core->get_option( 'redirects_404_exclude', "/wp-admin/*\n/feed*\n/xmlrpc.php" );
288 if ( !is_string( $raw ) ) { return array(); }
289 $lines = preg_split( "/\r\n|\n|\r/", $raw );
290 $patterns = array();
291 foreach ( $lines as $line ) {
292 $line = trim( $line );
293 if ( $line !== '' ) { $patterns[] = $line; }
294 }
295 return $patterns;
296 }
297
298 private function path_matches_wildcard( $path, $pattern ) {
299 // Convert simple wildcard to regex
300 $regex = '#^' . str_replace( '\*', '.*', preg_quote( $pattern, '#' ) ) . '$#i';
301 return (bool) @preg_match( $regex, $path );
302 }
303
304 private function is_asset_path( $path ) {
305 // Strip a possible double-extension suffix (e.g. ".jpg.webp") and check
306 // the final extension. Also catches common WP /wp-content/uploads/ patterns.
307 $assets = 'jpe?g|png|gif|webp|avif|svg|ico|bmp|tiff?' // images
308 . '|css|js|mjs|map' // web assets
309 . '|woff2?|ttf|otf|eot' // fonts
310 . '|mp[34]|m4[av]|wav|ogg|webm|mov|avi|wmv|flv' // media
311 . '|pdf|zip|gz|rar|7z|tar' // documents/archives
312 . '|xml|txt|csv|json'; // data
313 return (bool) @preg_match( '/\.(' . $assets . ')(\.[a-z0-9]{2,5})?$/i', $path );
314 }
315
316 private function is_crawler() {
317 $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : '';
318 if ( empty( $ua ) ) { return false; }
319 $markers = array( 'bot', 'crawler', 'spider', 'scraper', 'slurp', 'baiduspider', 'yandex', 'facebookexternalhit', 'twitterbot', 'linkedinbot' );
320 foreach ( $markers as $m ) {
321 if ( stripos( $ua, $m ) !== false ) { return true; }
322 }
323 return false;
324 }
325
326 public function prune_old_404s() {
327 $days = intval( $this->core->get_option( 'redirects_404_retention_days', 30 ) );
328 if ( $days <= 0 ) { return; }
329 global $wpdb;
330 $wpdb->query( $wpdb->prepare(
331 "DELETE FROM $this->log_404_table WHERE last_hit < DATE_SUB(NOW(), INTERVAL %d DAY)",
332 $days
333 ) );
334 }
335
336 #endregion
337
338 #region Slug auto-redirect
339
340 public function maybe_auto_redirect_on_slug_change( $post_id, $post_after, $post_before ) {
341 if ( $post_after->post_status !== 'publish' ) { return; }
342 if ( $post_before->post_name === $post_after->post_name ) { return; }
343 if ( empty( $post_before->post_name ) ) { return; }
344
345 $post_type_obj = get_post_type_object( $post_after->post_type );
346 if ( !$post_type_obj || empty( $post_type_obj->public ) ) { return; }
347
348 // Build the old permalink by temporarily restoring the previous slug
349 $old_url = $this->compute_old_permalink( $post_before, $post_after );
350 $new_url = get_permalink( $post_after );
351
352 if ( !$old_url || !$new_url || $old_url === $new_url ) { return; }
353
354 $old_path = $this->normalize_path( wp_parse_url( $old_url, PHP_URL_PATH ) );
355 $new_path = $this->normalize_path( wp_parse_url( $new_url, PHP_URL_PATH ) );
356
357 if ( $old_path === $new_path ) { return; }
358
359 $this->save_redirect( array(
360 'source_url' => $old_path,
361 'target_url' => $new_path,
362 'match_type' => 'exact',
363 'status_code' => 301,
364 'enabled' => 1,
365 'notes' => 'auto: slug change',
366 ), true );
367 }
368
369 private function compute_old_permalink( $post_before, $post_after ) {
370 // Trick get_permalink into using the old slug by passing a cloned post object
371 $clone = clone $post_after;
372 $clone->post_name = $post_before->post_name;
373 // If parent or post_status changed, try to keep the historical structure
374 $clone->post_status = 'publish';
375 return get_permalink( $clone );
376 }
377
378 #endregion
379
380 #region Public API (used by REST handlers)
381
382 public function get_request_path() {
383 if ( empty( $_SERVER['REQUEST_URI'] ) ) { return ''; }
384 $uri = $_SERVER['REQUEST_URI'];
385 $qpos = strpos( $uri, '?' );
386 if ( $qpos !== false ) { $uri = substr( $uri, 0, $qpos ); }
387 return $uri;
388 }
389
390 public function normalize_path( $path ) {
391 if ( empty( $path ) ) { return '/'; }
392 $path = '/' . ltrim( $path, '/' );
393 // Strip trailing slash except for root
394 if ( strlen( $path ) > 1 ) {
395 $path = rtrim( $path, '/' );
396 }
397 return $path;
398 }
399
400 public function list_redirects( $args = array() ) {
401 global $wpdb;
402
403 $defaults = array(
404 'search' => '',
405 'sort' => 'created_at',
406 'order' => 'DESC',
407 'page' => 1,
408 'limit' => 50,
409 'enabled' => null,
410 );
411 $args = wp_parse_args( $args, $defaults );
412
413 $where = array( '1=1' );
414 $values = array();
415
416 if ( $args['search'] !== '' ) {
417 $where[] = '(source_url LIKE %s OR target_url LIKE %s OR notes LIKE %s)';
418 $like = '%' . $wpdb->esc_like( $args['search'] ) . '%';
419 $values[] = $like; $values[] = $like; $values[] = $like;
420 }
421 if ( $args['enabled'] !== null ) {
422 $where[] = 'enabled = %d';
423 $values[] = (int) $args['enabled'];
424 }
425
426 $allowed_sort = array( 'created_at', 'updated_at', 'last_hit', 'hits', 'source_url', 'status_code' );
427 $sort = in_array( $args['sort'], $allowed_sort, true ) ? $args['sort'] : 'created_at';
428 $order = strtoupper( $args['order'] ) === 'ASC' ? 'ASC' : 'DESC';
429
430 $page = max( 1, intval( $args['page'] ) );
431 $limit = max( 1, min( 200, intval( $args['limit'] ) ) );
432 $offset = ( $page - 1 ) * $limit;
433
434 $where_sql = implode( ' AND ', $where );
435
436 $count_sql = "SELECT COUNT(*) FROM $this->redirects_table WHERE $where_sql";
437 $total = !empty( $values )
438 ? (int) $wpdb->get_var( $wpdb->prepare( $count_sql, ...$values ) )
439 : (int) $wpdb->get_var( $count_sql );
440
441 $sql = "SELECT * FROM $this->redirects_table WHERE $where_sql ORDER BY $sort $order LIMIT %d OFFSET %d";
442 $values[] = $limit; $values[] = $offset;
443
444 $rows = $wpdb->get_results( $wpdb->prepare( $sql, ...$values ), ARRAY_A );
445
446 return array(
447 'total' => $total,
448 'page' => $page,
449 'limit' => $limit,
450 'rows' => $rows ?: array(),
451 );
452 }
453
454 public function list_404s( $args = array() ) {
455 global $wpdb;
456
457 $defaults = array(
458 'search' => '',
459 'sort' => 'last_hit',
460 'order' => 'DESC',
461 'page' => 1,
462 'limit' => 50,
463 'include_ignored' => false,
464 );
465 $args = wp_parse_args( $args, $defaults );
466
467 $where = array( '1=1' );
468 $values = array();
469
470 if ( !$args['include_ignored'] ) {
471 $where[] = 'ignored = 0';
472 }
473 if ( $args['search'] !== '' ) {
474 $where[] = '(url LIKE %s OR last_referer LIKE %s)';
475 $like = '%' . $wpdb->esc_like( $args['search'] ) . '%';
476 $values[] = $like; $values[] = $like;
477 }
478
479 $allowed_sort = array( 'last_hit', 'first_hit', 'hits', 'url' );
480 $sort = in_array( $args['sort'], $allowed_sort, true ) ? $args['sort'] : 'last_hit';
481 $order = strtoupper( $args['order'] ) === 'ASC' ? 'ASC' : 'DESC';
482
483 $page = max( 1, intval( $args['page'] ) );
484 $limit = max( 1, min( 200, intval( $args['limit'] ) ) );
485 $offset = ( $page - 1 ) * $limit;
486
487 $where_sql = implode( ' AND ', $where );
488
489 $count_sql = "SELECT COUNT(*) FROM $this->log_404_table WHERE $where_sql";
490 $total = !empty( $values )
491 ? (int) $wpdb->get_var( $wpdb->prepare( $count_sql, ...$values ) )
492 : (int) $wpdb->get_var( $count_sql );
493
494 $sql = "SELECT * FROM $this->log_404_table WHERE $where_sql ORDER BY $sort $order LIMIT %d OFFSET %d";
495 $values[] = $limit; $values[] = $offset;
496
497 $rows = $wpdb->get_results( $wpdb->prepare( $sql, ...$values ), ARRAY_A );
498
499 return array(
500 'total' => $total,
501 'page' => $page,
502 'limit' => $limit,
503 'rows' => $rows ?: array(),
504 );
505 }
506
507 /**
508 * Create or update a redirect rule.
509 * @param array $data Rule fields.
510 * @param bool $skip_if_exists If true, do nothing when an exact rule for this source already exists.
511 * @return array|WP_Error Saved row or error.
512 */
513 public function save_redirect( $data, $skip_if_exists = false ) {
514 global $wpdb;
515
516 $id = isset( $data['id'] ) ? intval( $data['id'] ) : 0;
517 $source_url = isset( $data['source_url'] ) ? trim( $data['source_url'] ) : '';
518 $target_url = isset( $data['target_url'] ) ? trim( $data['target_url'] ) : '';
519 $match_type = isset( $data['match_type'] ) && $data['match_type'] === 'regex' ? 'regex' : 'exact';
520 $status_code = isset( $data['status_code'] ) ? intval( $data['status_code'] ) : 301;
521 $enabled = isset( $data['enabled'] ) ? (int) (bool) $data['enabled'] : 1;
522 $notes = isset( $data['notes'] ) ? mb_substr( (string) $data['notes'], 0, 255 ) : null;
523
524 if ( $source_url === '' ) {
525 return new WP_Error( 'invalid_source', 'Source URL is required.' );
526 }
527 if ( $status_code !== 410 && $target_url === '' ) {
528 return new WP_Error( 'invalid_target', 'Target URL is required (except for 410 Gone).' );
529 }
530 $allowed_status = array( 301, 302, 307, 308, 410 );
531 if ( !in_array( $status_code, $allowed_status, true ) ) {
532 $status_code = 301;
533 }
534
535 // Pro gate: regex requires Pro
536 if ( $match_type === 'regex' && !class_exists( 'MeowPro_MWSEO_Core' ) ) {
537 return new WP_Error( 'pro_required', 'Regex match type requires SEO Engine Pro.' );
538 }
539
540 // Validate regex compiles
541 if ( $match_type === 'regex' ) {
542 $test_pattern = '#' . str_replace( '#', '\\#', $source_url ) . '#';
543 if ( @preg_match( $test_pattern, '' ) === false ) {
544 return new WP_Error( 'invalid_regex', 'Invalid regular expression.' );
545 }
546 }
547
548 // Normalize exact source to a path when given a full URL on same host
549 if ( $match_type === 'exact' ) {
550 $source_url = $this->normalize_source_for_storage( $source_url );
551 }
552
553 $source_hash = md5( $match_type === 'exact' ? $source_url : $source_url );
554
555 if ( $skip_if_exists && $match_type === 'exact' ) {
556 $existing = $wpdb->get_var( $wpdb->prepare(
557 "SELECT id FROM $this->redirects_table WHERE source_hash = %s AND match_type = 'exact' LIMIT 1",
558 $source_hash
559 ) );
560 if ( $existing ) { return array( 'id' => (int) $existing, 'skipped' => true ); }
561 }
562
563 $row = array(
564 'source_url' => mb_substr( $source_url, 0, 500 ),
565 'source_hash' => $source_hash,
566 'match_type' => $match_type,
567 'target_url' => mb_substr( $target_url, 0, 500 ),
568 'status_code' => $status_code,
569 'enabled' => $enabled,
570 'notes' => $notes,
571 'updated_at' => current_time( 'mysql' ),
572 );
573 $formats = array( '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s' );
574
575 if ( $id > 0 ) {
576 $wpdb->update( $this->redirects_table, $row, array( 'id' => $id ), $formats, array( '%d' ) );
577 } else {
578 $row['created_at'] = current_time( 'mysql' );
579 $formats[] = '%s';
580 $wpdb->insert( $this->redirects_table, $row, $formats );
581 $id = (int) $wpdb->insert_id;
582 }
583
584 $this->flush_caches();
585
586 return $this->get_redirect( $id );
587 }
588
589 public function import_from_rank_math() {
590 global $wpdb;
591
592 $rm_table = $wpdb->prefix . 'rank_math_redirections';
593 $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $rm_table ) ) === $rm_table;
594 if ( !$table_exists ) {
595 return 0;
596 }
597
598 // Make sure our own table exists before inserting (the module may be disabled).
599 $this->maybe_create_redirects_table();
600
601 $rows = $wpdb->get_results( "SELECT sources, url_to, header_code, status FROM $rm_table", ARRAY_A );
602 if ( empty( $rows ) ) {
603 return 0;
604 }
605
606 $imported = 0;
607 foreach ( $rows as $row ) {
608 $sources = maybe_unserialize( $row['sources'] );
609 if ( !is_array( $sources ) ) {
610 continue;
611 }
612
613 $target = isset( $row['url_to'] ) ? trim( $row['url_to'] ) : '';
614 $status_code = isset( $row['header_code'] ) ? intval( $row['header_code'] ) : 301;
615 $enabled = ( isset( $row['status'] ) && $row['status'] === 'active' ) ? 1 : 0;
616
617 foreach ( $sources as $source ) {
618 if ( empty( $source['pattern'] ) ) {
619 continue;
620 }
621
622 $pattern = $source['pattern'];
623 $comparison = isset( $source['comparison'] ) ? $source['comparison'] : 'exact';
624
625 // Map Rank Math comparison types to SEO Engine's match_type (exact|regex).
626 $match_type = 'exact';
627 switch ( $comparison ) {
628 case 'regex':
629 $match_type = 'regex';
630 break;
631 case 'contains':
632 $match_type = 'regex';
633 $pattern = '.*' . preg_quote( $pattern, '#' ) . '.*';
634 break;
635 case 'start':
636 $match_type = 'regex';
637 $pattern = '^' . preg_quote( $pattern, '#' );
638 break;
639 case 'end':
640 $match_type = 'regex';
641 $pattern = preg_quote( $pattern, '#' ) . '$';
642 break;
643 case 'exact':
644 default:
645 $match_type = 'exact';
646 break;
647 }
648
649 $result = $this->save_redirect( array(
650 'source_url' => $pattern,
651 'target_url' => $target,
652 'match_type' => $match_type,
653 'status_code' => $status_code,
654 'enabled' => $enabled,
655 'notes' => 'Imported from Rank Math',
656 ), true );
657
658 // Count only rows we actually inserted (skip existing/errors like Pro-gated regex).
659 if ( is_array( $result ) && empty( $result['skipped'] ) ) {
660 $imported++;
661 }
662 }
663 }
664
665 if ( $imported > 0 ) {
666 $this->core->log( "↪️ Imported {$imported} redirect(s) from Rank Math." );
667 }
668
669 return $imported;
670 }
671
672 private function normalize_source_for_storage( $source ) {
673 // If a full URL was provided for the current site, strip down to the path
674 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
675 $source_host = wp_parse_url( $source, PHP_URL_HOST );
676 if ( $source_host && $site_host && strcasecmp( $source_host, $site_host ) === 0 ) {
677 $path = wp_parse_url( $source, PHP_URL_PATH );
678 return $this->normalize_path( $path );
679 }
680 // If it's a path, normalize
681 if ( !$source_host ) {
682 return $this->normalize_path( $source );
683 }
684 return $source;
685 }
686
687 public function get_redirect( $id ) {
688 global $wpdb;
689 $row = $wpdb->get_row( $wpdb->prepare(
690 "SELECT * FROM $this->redirects_table WHERE id = %d", intval( $id )
691 ), ARRAY_A );
692 return $row ?: null;
693 }
694
695 public function delete_redirects( $ids ) {
696 global $wpdb;
697 $ids = array_map( 'intval', (array) $ids );
698 $ids = array_filter( $ids );
699 if ( empty( $ids ) ) { return 0; }
700 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
701 $count = $wpdb->query( $wpdb->prepare(
702 "DELETE FROM $this->redirects_table WHERE id IN ($placeholders)",
703 ...$ids
704 ) );
705 $this->flush_caches();
706 return (int) $count;
707 }
708
709 public function bulk_redirects( $action, $ids ) {
710 global $wpdb;
711 $ids = array_map( 'intval', (array) $ids );
712 $ids = array_filter( $ids );
713 if ( empty( $ids ) ) { return 0; }
714 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
715
716 switch ( $action ) {
717 case 'enable':
718 $count = $wpdb->query( $wpdb->prepare(
719 "UPDATE $this->redirects_table SET enabled = 1 WHERE id IN ($placeholders)",
720 ...$ids
721 ) );
722 break;
723 case 'disable':
724 $count = $wpdb->query( $wpdb->prepare(
725 "UPDATE $this->redirects_table SET enabled = 0 WHERE id IN ($placeholders)",
726 ...$ids
727 ) );
728 break;
729 case 'delete':
730 return $this->delete_redirects( $ids );
731 default:
732 return 0;
733 }
734 $this->flush_caches();
735 return (int) $count;
736 }
737
738 public function convert_404( $id, $extra = array() ) {
739 global $wpdb;
740 $id = intval( $id );
741 $row = $wpdb->get_row( $wpdb->prepare(
742 "SELECT * FROM $this->log_404_table WHERE id = %d", $id
743 ), ARRAY_A );
744 if ( !$row ) { return new WP_Error( 'not_found', '404 entry not found.' ); }
745
746 $data = array(
747 'source_url' => $row['url'],
748 'target_url' => isset( $extra['target_url'] ) ? $extra['target_url'] : '',
749 'status_code' => isset( $extra['status_code'] ) ? intval( $extra['status_code'] ) : 301,
750 'match_type' => 'exact',
751 'enabled' => 1,
752 'notes' => isset( $extra['notes'] ) ? $extra['notes'] : 'Created from 404 log',
753 );
754
755 $result = $this->save_redirect( $data );
756 if ( is_wp_error( $result ) ) { return $result; }
757
758 // Delete the 404 entry now that it's been converted
759 $wpdb->delete( $this->log_404_table, array( 'id' => $id ), array( '%d' ) );
760
761 return $result;
762 }
763
764 public function ignore_404( $ids, $ignored = true ) {
765 global $wpdb;
766 $ids = array_map( 'intval', (array) $ids );
767 $ids = array_filter( $ids );
768 if ( empty( $ids ) ) { return 0; }
769 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
770 return (int) $wpdb->query( $wpdb->prepare(
771 "UPDATE $this->log_404_table SET ignored = %d WHERE id IN ($placeholders)",
772 $ignored ? 1 : 0,
773 ...$ids
774 ) );
775 }
776
777 public function delete_404s( $ids ) {
778 global $wpdb;
779 $ids = array_map( 'intval', (array) $ids );
780 $ids = array_filter( $ids );
781 if ( empty( $ids ) ) { return 0; }
782 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
783 return (int) $wpdb->query( $wpdb->prepare(
784 "DELETE FROM $this->log_404_table WHERE id IN ($placeholders)",
785 ...$ids
786 ) );
787 }
788
789 public function clear_404s( $older_than_days = 0 ) {
790 global $wpdb;
791 $days = intval( $older_than_days );
792 if ( $days > 0 ) {
793 return (int) $wpdb->query( $wpdb->prepare(
794 "DELETE FROM $this->log_404_table WHERE last_hit < DATE_SUB(NOW(), INTERVAL %d DAY)",
795 $days
796 ) );
797 }
798 return (int) $wpdb->query( "TRUNCATE TABLE $this->log_404_table" );
799 }
800
801 private function flush_caches() {
802 // Object cache uses keys keyed by source_hash and a singleton 'all_regex' key.
803 // Clear the regex cache; per-hash entries are fine to leave because TTL is 5min.
804 wp_cache_delete( 'all_regex', 'mwseo_redirects' );
805 }
806
807 #endregion
808 }
809