PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / frontend / SlugChangeHandler.php

SlugChangeHandler.php in 404 Solution trunk, at includes/frontend/SlugChangeHandler.php

427 lines 16.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 class ABJ_404_Solution_SlugChangeHandler {
9
10 /** @var self|null */
11 private static $instance = null;
12
13 /** @var mixed */
14 private $contentRepository;
15
16 /** @var mixed */
17 private $redirectsRepository;
18
19 /** @var ABJ_404_Solution_Logging */
20 private $logger;
21
22 /**
23 * Track post IDs already processed within the current request.
24 * WordPress fires save_post multiple times per save; this prevents duplicate redirects.
25 * @var array<int, bool>
26 */
27 private static $processedPosts = [];
28
29 /**
30 * Test seam: clear the per-request processed-post guard set without
31 * private-field reflection. Resets to the empty-array default (not null)
32 * so production loops over the property stay valid (M105 singleton-reset).
33 *
34 * @return void
35 */
36 public static function resetForTests() {
37 self::$processedPosts = [];
38 }
39
40 /**
41 * @param ABJ_404_Solution_ContentRepository|null $contentRepository Content repository
42 * @param ABJ_404_Solution_RedirectsRepository|null $redirectsRepository Redirects repository
43 * @param ABJ_404_Solution_Logging|null $logging Logging service
44 */
45 public function __construct($contentRepository = null, $redirectsRepository = null, $logging = null) {
46 $this->contentRepository = $contentRepository;
47 $this->redirectsRepository = $redirectsRepository;
48 $this->logger = $logging !== null ? $logging : abj_service('logging');
49 }
50
51 /** @return mixed */
52 private function getContentRepository() {
53 return $this->contentRepository !== null ? $this->contentRepository : abj_service('content_repository');
54 }
55
56 /** @return mixed */
57 private function getRedirectsRepository() {
58 return $this->redirectsRepository !== null ? $this->redirectsRepository : abj_service('redirects_repository');
59 }
60
61 /**
62 * @param int $postId
63 * @return string|null
64 */
65 private function getPermalinkFromCache(int $postId): ?string {
66 $repository = $this->getContentRepository();
67 if (!is_object($repository) || !method_exists($repository, 'getPermalinkFromCache')) {
68 return null;
69 }
70 $permalink = call_user_func(array($repository, 'getPermalinkFromCache'), $postId);
71 return is_scalar($permalink) ? (string)$permalink : null;
72 }
73
74 /**
75 * @param string $oldSlug
76 * @param string $status
77 * @param string $type
78 * @param string $finalDest
79 * @param string $redirectCode
80 * @param string $engine
81 * @return void
82 */
83 private function setupRedirect(string $oldSlug, string $status, string $type, string $finalDest, string $redirectCode, string $engine): void {
84 $repository = $this->getRedirectsRepository();
85 if (!is_object($repository) || !method_exists($repository, 'setupRedirect')) {
86 return;
87 }
88 $spec = ABJ_404_Solution_RedirectSpec::create($oldSlug, $status, $type, $finalDest, $redirectCode, 0, $engine);
89 call_user_func(array($repository, 'setupRedirect'), $spec);
90 }
91
92 /**
93 * Get singleton instance
94 * @return ABJ_404_Solution_SlugChangeHandler
95 */
96 public static function getInstance() {
97 if (self::$instance == null) {
98 self::$instance = new ABJ_404_Solution_SlugChangeHandler();
99 }
100 return self::$instance;
101 }
102
103 /**
104 * Initialize the handler and register WordPress hooks
105 * @return void
106 */
107 static function init() {
108 $me = abj_service('slug_change_handler');
109 add_action('save_post', array($me, 'save_postHandler'), 10, 3);
110 add_action('transition_post_status', array($me, 'postStatusTransitionHandler'), 10, 3);
111 add_action('before_delete_post', array($me, 'beforeDeletePostHandler'), 10, 2);
112 }
113
114 /** We'll just make sure the permalink gets updated in case it's changed.
115 * @param int $post_id The post ID.
116 * @param \WP_Post $post The post object.
117 * @param bool $update Whether this is an existing post being updated or not.
118 * @return void
119 */
120 function save_postHandler($post_id, $post, $update) {
121 try {
122 // Prevent duplicate processing within same request.
123 // WordPress fires save_post multiple times per save operation;
124 // the guard must live directly in the registered hook callback
125 // (not delegated to a private *Impl() method) so a static audit
126 // of the registered handler can verify it holds without having
127 // to follow call graphs. See HookLifecycleAuditTest (Pattern 11).
128 if (isset(self::$processedPosts[$post_id])) {
129 $this->logger->debugMessage(__CLASS__ . "/" . __FUNCTION__ .
130 ": Already processed post ID " . $post_id . " in this request (skipped).");
131 return;
132 }
133
134 $this->save_postHandlerImpl($post_id, $post, $update);
135 } catch (\Throwable $e) {
136 // save_post fires on every post save site-wide (admin, REST,
137 // import, other plugins' programmatic wp_insert_post() calls).
138 // A transient failure resolving this plugin's services (the
139 // VRMU incident: a class momentarily missing during a plugin
140 // self-update racing a live request) must not crash the
141 // save/request that triggered it.
142 $this->logHandlerFailure('save_postHandler', $e);
143 }
144 }
145
146 /**
147 * @param int $post_id
148 * @param \WP_Post $post
149 * @param bool $update
150 * @return void
151 */
152 private function save_postHandlerImpl($post_id, $post, $update): void {
153 $abj404logging = $this->logger;
154
155 // Request-level dedup is enforced by the caller, save_postHandler(),
156 // before this method is ever invoked (self::$processedPosts[$post_id]
157 // guard). Not re-checked here since this method is private and has
158 // exactly one call site.
159
160 // Defensive: WordPress hook may pass unexpected types at runtime.
161 if (!is_object($post) || !property_exists($post, 'post_name')) {
162 $abj404logging->debugMessage(__CLASS__ . "/" . __FUNCTION__ .
163 ": Invalid post object or missing post_name property for post ID " . $post_id . ".");
164 return;
165 }
166
167 if (!$update) {
168 $abj404logging->debugMessage(__CLASS__ . "/" . __FUNCTION__ .
169 ": Non-update skipped for post ID " . $post_id . ".");
170 return;
171 }
172
173 // Check if we should create a redirect (respects per-post override from editor)
174 $options = abj_service('options_repository')->getOptions();
175
176 // Check for per-post override from Quick Edit, Classic Editor, or Gutenberg
177 if (class_exists('ABJ_404_Solution_PostEditorIntegration')) {
178 $shouldCreate = ABJ_404_Solution_PostEditorIntegration::shouldCreateRedirect($post_id, $options);
179 } else {
180 // Fallback to global setting if PostEditorIntegration not loaded
181 $shouldCreate = @$options['auto_slugs'] == '1';
182 }
183
184 if (!$shouldCreate) {
185 $abj404logging->debugMessage(__CLASS__ . "/" . __FUNCTION__ . ": Auto slug redirects off " .
186 "or disabled for this post (skipped) (post ID " . $post_id . ").");
187 return;
188 }
189
190 // Use post_status from $post object instead of database query
191 /** @var string|false $postStatus */
192 $postStatus = property_exists($post, 'post_status') ? $post->post_status : get_post_status($post_id);
193 if (!in_array($postStatus, array('publish', 'published'))) {
194 $abj404logging->debugMessage(__CLASS__ . "/" . __FUNCTION__ . ": Post status: " .
195 $postStatus . " (skipped) (post ID " . $post_id . ").");
196 return;
197 }
198
199 // get the old slug
200 $oldURL = $this->getPermalinkFromCache($post_id);
201
202 if ($oldURL === null || $oldURL === "") {
203 $abj404logging->debugMessage("Couldn't find old slug for updated page. ID " .
204 $post_id . ", old URL: " . $oldURL . ", post name: " . $post->post_name .
205 ", update: " . $update);
206 return;
207 }
208
209 $newURL = get_permalink($post);
210
211 // Defensive: get_permalink may return WP_Error via filters in some environments.
212 if (is_wp_error($newURL)) {
213 $abj404logging->debugMessage("Could not get permalink for post (WP_Error). ID: " .
214 $post_id . ", error: " . $newURL->get_error_message());
215 return;
216 }
217
218 if ($newURL === false || $newURL === '') {
219 $abj404logging->debugMessage("Could not get permalink for post (invalid return). ID: " .
220 $post_id);
221 return;
222 }
223
224 // Safely parse the old URL
225 $oldURLParsed = parse_url($oldURL);
226 if ($oldURLParsed === false) {
227 $abj404logging->debugMessage("Could not parse old URL (malformed). ID: " .
228 $post_id . ", URL: " . $oldURL);
229 return;
230 }
231
232 if (!isset($oldURLParsed['path']) || $oldURLParsed['path'] === '') {
233 $abj404logging->debugMessage("Old URL has no path component. ID: " .
234 $post_id . ", URL: " . $oldURL);
235 return;
236 }
237
238 $oldSlug = $oldURLParsed['path'];
239
240 if ($oldURL == $newURL) {
241 $abj404logging->debugMessage("Save post listener: Old and new URL are the same. (Ignored) " .
242 "ID: " . $post_id . ", old URL: " . $oldURL . ", old slug: " . $oldSlug .
243 ", new slug: " . $post->post_name . ", update: " . $update);
244
245 return;
246 }
247
248 // Mark as processed before creating redirect to prevent duplicates
249 self::$processedPosts[$post_id] = true;
250
251 // create a redirect from the old to the new.
252 $this->setupRedirect($oldSlug, (string)ABJ404_STATUS_AUTO, (string)ABJ404_TYPE_POST,
253 (string)$post_id, (isset($options['default_redirect']) && is_scalar($options['default_redirect'])) ? (string)$options['default_redirect'] : '301', 'slug change');
254 $abj404logging->infoMessage("Added automatic redirect after slug change from " .
255 $oldURL . ' to ' . $newURL . " for post ID " . $post_id);
256 }
257
258 /**
259 * Fires when a published post is moved to trash.
260 * Creates a redirect from the old permalink to the homepage.
261 *
262 * @param string $new_status New post status.
263 * @param string $old_status Old post status.
264 * @param \WP_Post $post Post object.
265 * @return void
266 */
267 function postStatusTransitionHandler($new_status, $old_status, $post) {
268 try {
269 $this->postStatusTransitionHandlerImpl($new_status, $old_status, $post);
270 } catch (\Throwable $e) {
271 // Same reasoning as save_postHandler(): this fires on every
272 // post status change site-wide and must not crash the request
273 // that triggered it.
274 $this->logHandlerFailure('postStatusTransitionHandler', $e);
275 }
276 }
277
278 /**
279 * @param string $new_status
280 * @param string $old_status
281 * @param \WP_Post $post
282 * @return void
283 */
284 private function postStatusTransitionHandlerImpl($new_status, $old_status, $post): void {
285 // Only care about published posts being trashed.
286 if ($old_status !== 'publish' || $new_status !== 'trash') {
287 return;
288 }
289
290 if (!is_object($post) || !property_exists($post, 'ID')) {
291 return;
292 }
293
294 $post_id = (int)$post->ID;
295
296 // Check option
297 $options = abj_service('options_repository')->getOptions();
298 if (!isset($options['auto_trash_redirect']) || $options['auto_trash_redirect'] != '1') {
299 return;
300 }
301
302 // Prevent duplicate processing within same request
303 if (isset(self::$processedPosts[$post_id])) {
304 return;
305 }
306
307 $oldURL = $this->getPermalinkFromCache($post_id);
308
309 if ($oldURL === null || $oldURL === '') {
310 return;
311 }
312
313 $oldURLParsed = parse_url($oldURL);
314 if ($oldURLParsed === false || !isset($oldURLParsed['path']) || $oldURLParsed['path'] === '') {
315 return;
316 }
317
318 $oldSlug = $oldURLParsed['path'];
319 $redirectCode = (isset($options['default_redirect']) && is_scalar($options['default_redirect'])) ? (string)$options['default_redirect'] : '301';
320
321 self::$processedPosts[$post_id] = true;
322
323 $this->setupRedirect($oldSlug, (string)ABJ404_STATUS_AUTO, (string)ABJ404_TYPE_HOME,
324 '0', $redirectCode, 'post trashed');
325
326 $this->logger->infoMessage(
327 "Added automatic redirect to homepage after post trashed. ID: " . $post_id . ", old URL: " . $oldURL);
328 }
329
330 /**
331 * Fires just before a published post is permanently deleted.
332 * Creates a redirect from the old permalink to the homepage.
333 *
334 * @param int $post_id Post ID.
335 * @param \WP_Post $post Post object.
336 * @return void
337 */
338 function beforeDeletePostHandler($post_id, $post) {
339 try {
340 $this->beforeDeletePostHandlerImpl($post_id, $post);
341 } catch (\Throwable $e) {
342 // Same reasoning as save_postHandler(): this fires on every
343 // post deletion site-wide and must not crash the request that
344 // triggered it.
345 $this->logHandlerFailure('beforeDeletePostHandler', $e);
346 }
347 }
348
349 /**
350 * Record a failure one of this class's three registered hook callbacks
351 * absorbed, at the severity the CAUSE deserves.
352 *
353 * The three callbacks are entry points for every post save, status change
354 * and deletion on the site, so they also absorb the seconds-long window in
355 * which WordPress is replacing this plugin's own files (production report
356 * 266: `Class "ABJ_404_Solution_TableReadinessGate" not found`, raised from
357 * save_post during a wp-cron run that had booted 4.3.2 while 4.3.3 landed
358 * on disk). Reporting that as an error emails the maintainer about a
359 * hosting event that fixes itself on the next request; a genuine failure
360 * in the redirect-creation path still reports as an error.
361 *
362 * @param string $callback Name of the hook callback that caught it.
363 * @param \Throwable $e
364 * @return void
365 */
366 private function logHandlerFailure($callback, \Throwable $e) {
367 $message = $callback . ' failed: ' . get_class($e) . ': ' . $e->getMessage();
368 if (function_exists('abj404_logCallbackFailure')) {
369 abj404_logCallbackFailure($this->logger, $message, $e);
370 return;
371 }
372 $this->logger->errorMessage($message, $e instanceof \Exception ? $e : null);
373 }
374
375 /**
376 * @param int $post_id
377 * @param \WP_Post $post
378 * @return void
379 */
380 private function beforeDeletePostHandlerImpl($post_id, $post): void {
381 if (!is_object($post) || !property_exists($post, 'post_status')) {
382 return;
383 }
384
385 // Only published posts (not already-trashed posts being force-deleted).
386 $postStatus = (string)$post->post_status;
387 if (!in_array($postStatus, array('publish', 'published'), true)) {
388 return;
389 }
390
391 // Check option
392 $options = abj_service('options_repository')->getOptions();
393 if (!isset($options['auto_trash_redirect']) || $options['auto_trash_redirect'] != '1') {
394 return;
395 }
396
397 $post_id = (int)$post_id;
398
399 // Prevent duplicate processing within same request
400 if (isset(self::$processedPosts[$post_id])) {
401 return;
402 }
403
404 $oldURL = $this->getPermalinkFromCache($post_id);
405
406 if ($oldURL === null || $oldURL === '') {
407 return;
408 }
409
410 $oldURLParsed = parse_url($oldURL);
411 if ($oldURLParsed === false || !isset($oldURLParsed['path']) || $oldURLParsed['path'] === '') {
412 return;
413 }
414
415 $oldSlug = $oldURLParsed['path'];
416 $redirectCode = (isset($options['default_redirect']) && is_scalar($options['default_redirect'])) ? (string)$options['default_redirect'] : '301';
417
418 self::$processedPosts[$post_id] = true;
419
420 $this->setupRedirect($oldSlug, (string)ABJ404_STATUS_AUTO, (string)ABJ404_TYPE_HOME,
421 '0', $redirectCode, 'post deleted');
422
423 $this->logger->infoMessage(
424 "Added automatic redirect to homepage after post deleted. ID: " . $post_id . ", old URL: " . $oldURL);
425 }
426 }
427