| @@ -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 | ); |