PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / redirections / class-metasync-auto-redirect.php

class-metasync-auto-redirect.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at redirections/class-metasync-auto-redirect.php

413 lines 14.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Automatic Redirect on Slug Change
5 *
6 * Creates 301 redirects automatically when post/page URL slugs are changed.
7 * This preserves SEO link equity when content URLs are modified.
8 *
9 * @link https://searchatlas.com
10 * @since 1.0.0
11 * @package Metasync
12 * @subpackage Metasync/redirections
13 * @author Engineering Team <[email protected]>
14 */
15
16 // Abort if this file is accessed directly.
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 class Metasync_Auto_Redirect
22 {
23 /**
24 * Reference to the redirection database class
25 *
26 * @var Metasync_Redirection_Database
27 */
28 private $db_redirection;
29
30 /**
31 * Post types to monitor for slug changes
32 *
33 * @var array
34 */
35 private $monitored_post_types = array('post', 'page');
36
37 /**
38 * Constructor
39 *
40 * @param Metasync_Redirection_Database $db_redirection Database class reference
41 */
42 public function __construct($db_redirection)
43 {
44 $this->db_redirection = $db_redirection;
45 }
46
47 /**
48 * Initialize hooks
49 */
50 public function init()
51 {
52 add_action('post_updated', array($this, 'check_slug_change'), 10, 3);
53 add_action('admin_notices', array($this, 'display_auto_redirect_notice'));
54 }
55
56 /**
57 * Get supported post types
58 *
59 * @return array
60 */
61 private function get_supported_post_types()
62 {
63 return apply_filters('metasync_auto_redirect_post_types', $this->monitored_post_types);
64 }
65
66 /**
67 * Check if slug changed and create redirect if needed
68 *
69 * @param int $post_id Post ID
70 * @param WP_Post $post_after Post object after update
71 * @param WP_Post $post_before Post object before update
72 */
73 public function check_slug_change($post_id, $post_after, $post_before)
74 {
75 // Hard off-switch. Bulk slug operations and migrations need a way to
76 // stop rule creation entirely; the post-types filter was the only
77 // control before and required code. Default on preserves behavior.
78 if (!get_option('metasync_auto_redirect_enabled', 1)) {
79 return;
80 }
81
82 // Skip autosaves
83 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
84 return;
85 }
86
87 // Skip revisions
88 if (wp_is_post_revision($post_id)) {
89 return;
90 }
91
92 // Only check supported post types
93 if (!in_array($post_after->post_type, $this->get_supported_post_types(), true)) {
94 return;
95 }
96
97 // Only create redirects for published posts
98 if ($post_after->post_status !== 'publish') {
99 return;
100 }
101
102 // Skip if the post wasn't published before (new publish)
103 if ($post_before->post_status !== 'publish') {
104 return;
105 }
106
107 // Skip if slug is empty
108 if (empty($post_before->post_name) || empty($post_after->post_name)) {
109 return;
110 }
111
112 // Compare full URLs, not just the slug: moving a page under a new
113 // parent keeps the slug identical but changes its path, and that path
114 // change strands every existing link exactly like a rename does.
115 $old_url = $this->build_relative_url($post_before);
116 $new_url = $this->build_relative_url($post_after);
117
118 // Skip if URLs are the same
119 if ($old_url === $new_url) {
120 return;
121 }
122
123 // Create the redirect
124 $this->create_redirect($old_url, $new_url, $post_id);
125
126 // post_updated fires only for the edited post, but a hierarchical
127 // move renames every descendant's URL too — create theirs as well.
128 $this->create_redirects_for_descendants($post_id, $old_url, $new_url);
129 }
130
131 /**
132 * Build relative URL for a post
133 *
134 * @param WP_Post $post Post object
135 * @return string Relative URL path
136 */
137 private function build_relative_url($post)
138 {
139 $permalink = get_permalink($post);
140 $parsed = wp_parse_url($permalink);
141 $path = isset($parsed['path']) ? $parsed['path'] : '/';
142 return $path;
143 }
144
145 /**
146 * Create redirects for the published descendants of a renamed or moved
147 * post. A child's new URL comes from its now-updated permalink; its old
148 * URL is the same tail under the old parent path.
149 *
150 * @param int $post_id Parent post whose URL changed.
151 * @param string $old_parent_url Parent's previous relative URL.
152 * @param string $new_parent_url Parent's current relative URL.
153 */
154 private function create_redirects_for_descendants($post_id, $old_parent_url, $new_parent_url)
155 {
156 if ($old_parent_url === $new_parent_url) {
157 return;
158 }
159
160 $children = get_posts(array(
161 'post_type' => $this->get_supported_post_types(),
162 'post_parent' => $post_id,
163 'post_status' => 'publish',
164 'numberposts' => -1,
165 'fields' => 'ids',
166 'orderby' => 'ID',
167 'order' => 'ASC',
168 ));
169
170 foreach ($children as $child_id) {
171 $child_permalink = get_permalink($child_id);
172 if (!is_string($child_permalink) || $child_permalink === '') {
173 continue;
174 }
175 $parsed = wp_parse_url($child_permalink);
176 $new_child_url = isset($parsed['path']) ? $parsed['path'] : '/';
177 // The child's live URL extends the parent's NEW path; swap that
178 // prefix for the old one to reconstruct where it used to live.
179 $tail = (strpos($new_child_url, $new_parent_url) === 0)
180 ? substr($new_child_url, strlen($new_parent_url))
181 : false;
182 if ($tail === false || $tail === '') {
183 continue;
184 }
185 $old_child_url = $old_parent_url . $tail;
186 if ($old_child_url !== $new_child_url) {
187 $this->create_redirect($old_child_url, $new_child_url, $child_id);
188 }
189 $this->create_redirects_for_descendants($child_id, $old_child_url, $new_child_url);
190 }
191 }
192
193 /**
194 * Deactivate active rules whose source matches a now-live permalink, so a
195 * rename-back doesn't leave a rule bouncing visitors off a real page
196 * (e.g. /a→/b survives the post being renamed back to /a, 301-ing every
197 * visitor of the live /a into a 404). Rules intentionally configured on a
198 * live URL (the post-edit redirection meta box) are left alone.
199 *
200 * @param string $source_url The permalink that just went live.
201 */
202 private function deactivate_rules_for_source($source_url)
203 {
204 $source_url = trim((string) $source_url);
205 if ($source_url === '') {
206 return;
207 }
208 $source_norm = '/' . trim($source_url, '/');
209 if ($source_norm === '/') {
210 return; // never touch rules on the home URL
211 }
212
213 $all_redirects = $this->db_redirection->getAllActiveRecords();
214 if (empty($all_redirects)) {
215 return;
216 }
217
218 foreach ($all_redirects as $redirect) {
219 // The metabox legitimately points a live permalink elsewhere —
220 // that's its feature — so those rows are out of scope here.
221 if (strpos((string) $redirect->description, 'Post redirection meta box') === 0) {
222 continue;
223 }
224 $sources = unserialize($redirect->sources_from, array('allowed_classes' => false)); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
225 if (!is_array($sources)) {
226 continue;
227 }
228 foreach ($sources as $stored_key => $stored_value) {
229 // Legacy list rows keep the URL as the value under a numeric key.
230 $stored = is_int($stored_key) ? (string) $stored_value : (string) $stored_key;
231 if ($stored === '') {
232 continue;
233 }
234 $stored = '/' . trim($stored, '/');
235 if (rtrim($stored, '/') === rtrim($source_norm, '/')) {
236 $this->db_redirection->update_status(array((int) $redirect->id), 'inactive');
237 break;
238 }
239 }
240 }
241 }
242
243 /**
244 * Create a 301 redirect from old URL to new URL
245 *
246 * @param string $old_url Old URL path
247 * @param string $new_url New URL path
248 * @param int $post_id Post ID (for reference)
249 * @return bool Success or failure
250 */
251 private function create_redirect($old_url, $new_url, $post_id)
252 {
253 // The new URL is a live permalink now, so any active rule still
254 // redirecting FROM it is stale. Deactivate it before wiring the new
255 // rule.
256 $this->deactivate_rules_for_source($new_url);
257
258 // Check for duplicate redirects
259 $existing = $this->redirect_exists($old_url);
260 if ($existing !== false) {
261 // Update existing redirect instead of creating duplicate
262 return $this->update_existing_redirect($existing, $new_url);
263 }
264
265 // Loop detection — silently skip and log if creating this redirect would produce a chain back to $old_url
266 if (!class_exists('Metasync_Redirection')) {
267 require_once dirname(__FILE__) . '/class-metasync-redirection.php';
268 }
269 $db_ref = $this->db_redirection;
270 $redirection_helper = new Metasync_Redirection($db_ref);
271 $loop_chain = array();
272 if ($redirection_helper->would_create_loop($old_url, $new_url, $loop_chain)) {
273 error_log('[MetaSync] Auto-redirect skipped: loop detected — ' . implode(' → ', $loop_chain)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
274 return false;
275 }
276
277 // Prepare redirect data
278 $redirect_data = array(
279 'sources_from' => serialize(array($old_url => 'exact')),
280 'url_redirect_to' => $new_url,
281 'http_code' => 301,
282 'hits_count' => 0,
283 'status' => 'active',
284 'pattern_type' => 'exact',
285 'regex_pattern' => null,
286 'description' => sprintf(
287 'Auto-created: Slug change for post #%d on %s',
288 $post_id,
289 current_time('Y-m-d H:i:s')
290 ),
291 'created_at' => current_time('mysql'),
292 'updated_at' => current_time('mysql'),
293 );
294
295 // Add the redirect
296 $result = $this->db_redirection->add($redirect_data);
297
298 if ($result) {
299 // Set admin notice
300 set_transient('metasync_auto_redirect_notice', array(
301 'old_url' => $old_url,
302 'new_url' => $new_url,
303 'post_id' => $post_id,
304 ), 60);
305
306 do_action('metasync_auto_redirect_created', $old_url, $new_url, $post_id);
307 }
308
309 return $result !== false;
310 }
311
312 /**
313 * Check if a redirect already exists for the given source URL
314 *
315 * @param string $source_url Source URL to check
316 * @return object|false False if not found, redirect object if found
317 */
318 private function redirect_exists($source_url)
319 {
320 $all_redirects = $this->db_redirection->getAllActiveRecords();
321
322 if (empty($all_redirects)) {
323 return false;
324 }
325
326 foreach ($all_redirects as $redirect) {
327 $sources = unserialize($redirect->sources_from, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
328 if (is_array($sources) && isset($sources[$source_url])) {
329 return $redirect;
330 }
331 }
332
333 return false;
334 }
335
336 /**
337 * Update an existing redirect's destination
338 *
339 * @param object $existing Existing redirect object
340 * @param string $new_destination New destination URL
341 * @return bool Success or failure
342 */
343 private function update_existing_redirect($existing, $new_destination)
344 {
345 // Same loop guard as create_redirect(): a slug change can move the
346 // destination onto a path that is itself redirected back to this
347 // source, silently wiring up an A↔B ping-pong. 410/451 rules send
348 // no Location header, so they are exempt.
349 if (!in_array((int) $existing->http_code, [410, 451], true)) {
350 $sources = unserialize($existing->sources_from, ['allowed_classes' => false]); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
351 $source_url = '';
352 if (is_array($sources) && !empty($sources)) {
353 $keys = array_keys($sources);
354 $source_url = is_int($keys[0]) ? (string) $sources[$keys[0]] : (string) $keys[0];
355 }
356 if ($source_url !== '') {
357 if (!class_exists('Metasync_Redirection')) {
358 require_once dirname(__FILE__) . '/class-metasync-redirection.php';
359 }
360 $db_ref = $this->db_redirection;
361 $redirection_helper = new Metasync_Redirection($db_ref);
362 $loop_chain = array();
363 if ($redirection_helper->would_create_loop($source_url, $new_destination, $loop_chain)) {
364 error_log('[MetaSync] Auto-redirect update skipped: loop detected — ' . implode(' → ', $loop_chain)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
365 return false;
366 }
367 }
368 }
369
370 $updated_description = $existing->description . sprintf(
371 "\nUpdated destination on %s",
372 current_time('Y-m-d H:i:s')
373 );
374
375 $this->db_redirection->update(
376 array(
377 'url_redirect_to' => $new_destination,
378 'description' => $updated_description,
379 ),
380 $existing->id
381 );
382
383 return true;
384 }
385
386 /**
387 * Display admin notice after auto-redirect creation
388 */
389 public function display_auto_redirect_notice()
390 {
391 $notice = get_transient('metasync_auto_redirect_notice');
392
393 if (!$notice) {
394 return;
395 }
396
397 delete_transient('metasync_auto_redirect_notice');
398
399 $message = sprintf(
400 'A 301 redirect was automatically created from %s to %s because the URL slug was changed.',
401 '<code>' . esc_html($notice['old_url']) . '</code>',
402 '<code>' . esc_html($notice['new_url']) . '</code>'
403 );
404
405 echo '<div class="notice notice-success is-dismissible">';
406 echo '<p><strong>MetaSync Auto-Redirect:</strong> ' . wp_kses_post($message) . '</p>';
407 echo '</div>';
408 }
409 }
410
411
412
413