PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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
← All changes | redirections/class-metasync-auto-redirect.php +144 -9 2.6.21 → 2.7.1 View file →
@@ -71,8 +71,15 @@
71 71 * @param WP_Post $post_before Post object before update
72 72 */
73 73 public function check_slug_change($post_id, $post_after, $post_before)
74 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 +
75 82 // Skip autosaves
76 83 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
77 84 return;
78 85 }
@@ -96,19 +103,16 @@
96 103 if ($post_before->post_status !== 'publish') {
97 104 return;
98 105 }
99 106
100 - // Check if the slug actually changed
101 - if ($post_before->post_name === $post_after->post_name) {
102 - return;
103 - }
104 -
105 107 // Skip if slug is empty
106 108 if (empty($post_before->post_name) || empty($post_after->post_name)) {
107 109 return;
108 110 }
109 111
110 - // Build the old and new URLs
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.
111 115 $old_url = $this->build_relative_url($post_before);
112 116 $new_url = $this->build_relative_url($post_after);
113 117
114 118 // Skip if URLs are the same
@@ -117,8 +121,12 @@
117 121 }
118 122
119 123 // Create the redirect
120 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);
121 129 }
122 130
123 131 /**
124 132 * Build relative URL for a post
@@ -134,8 +142,106 @@
134 142 return $path;
135 143 }
136 144
137 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 + /**
138 244 * Create a 301 redirect from old URL to new URL
139 245 *
140 246 * @param string $old_url Old URL path
141 247 * @param string $new_url New URL path
@@ -143,14 +249,18 @@
143 249 * @return bool Success or failure
144 250 */
145 251 private function create_redirect($old_url, $new_url, $post_id)
146 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 +
147 258 // Check for duplicate redirects
148 259 $existing = $this->redirect_exists($old_url);
149 260 if ($existing !== false) {
150 261 // Update existing redirect instead of creating duplicate
151 - $this->update_existing_redirect($existing, $new_url);
152 - return true;
262 + return $this->update_existing_redirect($existing, $new_url);
153 263 }
154 264
155 265 // Loop detection — silently skip and log if creating this redirect would produce a chain back to $old_url
156 266 if (!class_exists('Metasync_Redirection')) {
@@ -159,9 +269,9 @@
159 269 $db_ref = $this->db_redirection;
160 270 $redirection_helper = new Metasync_Redirection($db_ref);
161 271 $loop_chain = array();
162 272 if ($redirection_helper->would_create_loop($old_url, $new_url, $loop_chain)) {
163 - error_log('[MetaSync] Auto-redirect skipped: loop detected — ' . implode(' → ', $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
164 274 return false;
165 275 }
166 276
167 277 // Prepare redirect data
@@ -231,8 +341,33 @@
231 341 * @return bool Success or failure
232 342 */
233 343 private function update_existing_redirect($existing, $new_destination)
234 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 +
235 370 $updated_description = $existing->description . sprintf(
236 371 "\nUpdated destination on %s",
237 372 current_time('Y-m-d H:i:s')
238 373 );