PluginProbe
404 Solution / 4.1.13
404 Solution v4.1.13
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 / PluginLogic.php

PluginLogic.php in 404 Solution 4.1.13, at includes/PluginLogic.php

1,250 lines 52.9 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 /* the glue that holds it together / everything else. */
9
10 require_once dirname(__FILE__) . '/PluginLogicTrait_UrlNormalization.php';
11 require_once dirname(__FILE__) . '/PluginLogicTrait_AdminActions.php';
12 require_once dirname(__FILE__) . '/PluginLogicTrait_ImportExport.php';
13 require_once dirname(__FILE__) . '/PluginLogicTrait_SettingsUpdate.php';
14 require_once dirname(__FILE__) . '/PluginLogicTrait_PageOrdering.php';
15 require_once dirname(__FILE__) . '/PluginLogicTrait_Lifecycle.php';
16
17 /**
18 * @phpstan-type PageObject object{id: int, post_parent: int, depth: int, post_type: string, post_title: string}
19 */
20 class ABJ_404_Solution_PluginLogic {
21
22 use ABJ_404_Solution_PluginLogicTrait_UrlNormalization;
23 use ABJ_404_Solution_PluginLogicTrait_AdminActions;
24 use ABJ_404_Solution_PluginLogicTrait_ImportExport;
25 use ABJ_404_Solution_PluginLogicTrait_SettingsUpdate;
26 use ABJ_404_Solution_PluginLogicTrait_PageOrdering;
27 use ABJ_404_Solution_PluginLogicTrait_Lifecycle;
28
29 /** @var ABJ_404_Solution_Functions */
30 private $f = null;
31
32 /** @var ABJ_404_Solution_DataAccess */
33 private $dao = null;
34
35 /** @var ABJ_404_Solution_Logging */
36 private $logger = null;
37
38 /** @var ABJ_404_Solution_ImportExportService|null */
39 private $importExportService = null;
40
41 /** @var string|null */
42 private $urlHomeDirectory = null;
43
44 /** @var int|null */
45 private $urlHomeDirectoryLength = null;
46
47 /** @var array<string, mixed>|null */
48 private $options = null;
49 /** @var array<string, mixed>|null */
50 private $resolvedOptionsSkipDbCheck = null;
51 /** @var array<string, mixed>|null */
52 private $resolvedOptionsWithDbCheck = null;
53
54 /** @var self|null */
55 private static $instance = null;
56
57 /** @var string|null */
58 private static $uniqID = null;
59
60 /** Use this to avoid an infinite loop when checking if a user has admin access or not.
61 * @var bool */
62 private static $checkingIsAdmin = false;
63
64 /** Allowed column names for orderby parameter.
65 * @var array<int, string> */
66 private static $allowedOrderbyColumns = [
67 'url',
68 'status',
69 'type',
70 'dest',
71 'final_dest',
72 'code',
73 'score',
74 'timestamp',
75 'created',
76 'lastused',
77 'last_used',
78 'logshits',
79 'remote_host',
80 'referrer',
81 'action',
82 'username'
83 ];
84
85 /** Allowed values for order parameter.
86 * @var array<int, string> */
87 private static $allowedOrderValues = ['ASC', 'DESC'];
88
89 /** @return ABJ_404_Solution_PluginLogic The singleton instance of the class. */
90 public static function getInstance() {
91 if (self::$instance !== null) {
92 return self::$instance;
93 }
94
95 // If the DI container is initialized, prefer it.
96 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
97 $resolved = ABJ_404_Solution_ServiceContainer::safeGet('plugin_logic');
98 if ($resolved instanceof self) {
99 self::$instance = $resolved;
100 return self::$instance;
101 }
102 }
103
104 self::$instance = new ABJ_404_Solution_PluginLogic();
105 self::$uniqID = uniqid("", true);
106
107 // these filters allow non-admins to have admin access to the plugin.
108 add_filter( 'user_has_cap',
109 'ABJ_404_Solution_PluginLogic::override_user_can_access_admin_page', 10, 4 );
110
111 return self::$instance;
112 }
113
114 /**
115 * Constructor with dependency injection.
116 * Dependencies are now explicit and visible.
117 *
118 * @param ABJ_404_Solution_Functions|null $functions String manipulation utilities
119 * @param ABJ_404_Solution_DataAccess|null $dataAccess Data access layer
120 * @param ABJ_404_Solution_Logging|null $logging Logging service
121 */
122 function __construct($functions = null, $dataAccess = null, $logging = null) {
123 // Use injected dependencies or fall back to getInstance() for backward compatibility
124 $this->f = $functions !== null ? $functions : abj_service('functions');
125 $this->dao = $dataAccess !== null ? $dataAccess : abj_service('data_access');
126 $this->logger = $logging !== null ? $logging : abj_service('logging');
127
128 $urlPath = parse_url(get_home_url(), PHP_URL_PATH);
129 // Fix MEDIUM #1 (5th review): Distinguish between parse failure (false) and no path (null)
130 if ($urlPath === false) {
131 $this->logger->warn("Malformed home URL detected: " . get_home_url());
132 $urlPath = '';
133 } else if ($urlPath === null) {
134 $urlPath = '';
135 }
136
137 // Fix HIGH #2 (4th review): Decode subdirectory for consistency with runtime processing
138 $decodedPath = $this->f->normalizeUrlString(rtrim($urlPath, '/'));
139 if (!is_string($decodedPath)) {
140 $decodedPath = '';
141 }
142 // Fix HIGH #3 (4th review): Remove null bytes and control characters for security
143 $cleaned = preg_replace('/[\x00-\x1F\x7F]/', '', $decodedPath);
144 $this->urlHomeDirectory = is_string($cleaned) ? $cleaned : $decodedPath;
145 $this->urlHomeDirectoryLength = $this->f->strlen($this->urlHomeDirectory);
146 }
147
148 /** @return ABJ_404_Solution_ImportExportService */
149 private function getImportExportService() {
150 if ($this->importExportService !== null) {
151 return $this->importExportService;
152 }
153
154 if (!class_exists('ABJ_404_Solution_ImportExportService')) {
155 require_once dirname(__FILE__) . '/ImportExportService.php';
156 }
157
158 $this->importExportService = new ABJ_404_Solution_ImportExportService($this->dao, $this->logger);
159 return $this->importExportService;
160 }
161
162 /** This replaces the current_user_can('administrator') function.
163 *
164 * Use the following to add a filter.
165 * // -------
166 * add_filter( 'abj404_userIsPluginAdmin', 'my_custom_function' );
167 * function my_custom_function( $value ) {
168 * // validate user can access the plugin here.
169 * return $value;
170 * }
171 * // -------
172 *
173 * @return bool true if $abj404logic->userIsPluginAdmin()
174 */
175 function userIsPluginAdmin() {
176 // avoid an infinite loop.
177 if (ABJ_404_Solution_PluginLogic::$checkingIsAdmin) {
178 return false;
179 }
180
181 ABJ_404_Solution_PluginLogic::$checkingIsAdmin = true;
182 try {
183 // Capability checks should not trigger DB upgrade checks (which can throw and lock users out).
184 $options = $this->getOptions(true);
185 $f = $this->f;
186 global $current_user;
187
188 // Baseline: admins have access. Prefer capability checks over role-name checks.
189 $isPluginAdmin = current_user_can('manage_options') || current_user_can('administrator');
190 if (function_exists('is_multisite') && is_multisite() && function_exists('is_super_admin') && is_super_admin()) {
191 $isPluginAdmin = true;
192 }
193
194 // check extra admins.
195 $extraAdmins = $options['plugin_admin_users'] ?? array();
196 $current_user_name = null;
197 if (isset($current_user)) {
198 $current_user_name = $current_user->user_login;
199 }
200 if ($current_user_name != null && $current_user_name != false) {
201 $check = false;
202 if (is_array($extraAdmins)) {
203 $extraAdmins = array_filter($extraAdmins,
204 array($f, 'removeEmptyCustom'));
205 $check = true;
206 } else if (is_string($extraAdmins)) {
207 $extraAdmins = $this->f->explodeNewline($extraAdmins);
208 $check = true;
209 }
210 /** @var array<int|string, mixed> $extraAdmins */
211 if ($check && is_array($extraAdmins) && in_array($current_user_name, $extraAdmins)) {
212 $isPluginAdmin = true;
213 }
214 }
215
216 // do the filter in case someone wants to add one
217 $filtered = apply_filters('abj404_userIsPluginAdmin', $isPluginAdmin);
218
219 // Log diagnostic details when access is denied, or when the filter changed the result.
220 if (!$filtered || ($filtered !== $isPluginAdmin)) {
221 $extraAdminsSummary = '';
222 $rawExtra = $options['plugin_admin_users'] ?? array();
223 if (is_array($rawExtra)) {
224 $extraAdminsSummary = implode(', ', array_filter($rawExtra));
225 } else if (is_string($rawExtra)) {
226 $extraAdminsSummary = $rawExtra;
227 }
228
229 $this->logger->debugMessage(
230 "userIsPluginAdmin detail: result=" . ($filtered ? 'true' : 'false') .
231 ", pre-filter=" . ($isPluginAdmin ? 'true' : 'false') .
232 ", manage_options=" . (current_user_can('manage_options') ? 'yes' : 'no') .
233 ", user=" . ($current_user_name ?? '(none)') .
234 ", plugin_admin_users=[" . esc_html($extraAdminsSummary) . "]" .
235 ($filtered !== $isPluginAdmin ? ", NOTE: abj404_userIsPluginAdmin filter changed the result" : "")
236 );
237 }
238
239 return $filtered;
240 } finally {
241 ABJ_404_Solution_PluginLogic::$checkingIsAdmin = false;
242 }
243 }
244
245 /**
246 * Verify a nonce for admin-link actions, without depending on the browser's Referer header.
247 *
248 * WordPress core's check_admin_referer() can fail in environments that strip referrers; in that
249 * case we fall back to wp_verify_nonce() using the same nonce value.
250 *
251 * @param string $action Nonce action string used in wp_nonce_url()
252 * @param string $queryArg Nonce query arg name (default '_wpnonce')
253 * @return bool
254 */
255 private function verifyLinkNonce($action, $queryArg = '_wpnonce') {
256 // Prefer check_admin_referer when available, but don't die on failure.
257 if (function_exists('check_admin_referer')) {
258 $ok = check_admin_referer($action, $queryArg);
259 if ($ok) {
260 return true;
261 }
262 }
263
264 if (!function_exists('wp_verify_nonce')) {
265 return false;
266 }
267
268 if (!isset($_REQUEST[$queryArg])) {
269 return false;
270 }
271
272 $nonce = sanitize_text_field(wp_unslash($_REQUEST[$queryArg]));
273 if ($nonce === '') {
274 return false;
275 }
276
277 return wp_verify_nonce($nonce, $action) !== false;
278 }
279
280 /**
281 * Get the current user's settings mode preference.
282 * @return string 'simple' or 'advanced'
283 */
284 function getSettingsMode() {
285 $user_id = get_current_user_id();
286 if (!$user_id) {
287 return 'simple';
288 }
289 $mode = get_user_meta($user_id, 'abj404_settings_mode', true);
290 return ($mode === 'advanced') ? 'advanced' : 'simple';
291 }
292
293 /**
294 * Set the current user's settings mode preference.
295 * @param string $mode 'simple' or 'advanced'
296 * @return bool|int Meta ID on success, false on failure
297 */
298 function setSettingsMode($mode) {
299 $user_id = get_current_user_id();
300 if (!$user_id) {
301 return false;
302 }
303 $valid_mode = ($mode === 'advanced') ? 'advanced' : 'simple';
304 return update_user_meta($user_id, 'abj404_settings_mode', $valid_mode);
305 }
306
307 /** Allow the user to be an admin for the plugin.
308 * @param array<string, bool> $allcaps
309 * @param array<int, string> $caps
310 * @param array<int, mixed> $args
311 * @param \WP_User $user
312 * @return array<string, bool> an array of the capabilities
313 */
314 static function override_user_can_access_admin_page( $allcaps, $caps, $args, $user ) {
315 // if it's not an admin page then we don't change anything.
316 if (!is_admin()) {
317 return $allcaps;
318 }
319
320 $abj404logic = abj_service('plugin_logic');
321
322 $isPluginAdmin = false;
323 $isViewing404AdminPage = false;
324
325 // is the user supposed to have access?
326 if ($abj404logic->userIsPluginAdmin()) {
327 $isPluginAdmin = true;
328 }
329
330 if ($isPluginAdmin) {
331 $userRequest = ABJ_404_Solution_UserRequest::getInstance();
332 $queryParts = $userRequest !== null ? $userRequest->getQueryString() : null;
333
334 // are we viewing a 404 plugin page?
335 if (is_string($queryParts) && strpos($queryParts, ABJ404_PP) !== false) {
336 $isViewing404AdminPage = true;
337 }
338 }
339
340 if ($isPluginAdmin && $isViewing404AdminPage) {
341 $allcaps['manage_options'] = true;
342 }
343
344 return $allcaps;
345 }
346
347 /** Forward to a real page for queries like ?p=10
348 * @global type $wp_query
349 * @param array<string, mixed> $options
350 * @return void
351 */
352 function tryNormalPostQuery(array $options): void {
353 global $wp_query;
354
355 // this is for requests like website.com/?p=123
356 $query = $wp_query->query;
357 // if it's not set then don't use it.
358 if (!isset($query['p'])) {
359 return;
360 }
361 $pageid = $query['p'];
362 if (!empty($pageid)) {
363 $rawPermalink = get_permalink($pageid);
364 $permalink = $this->f->normalizeUrlString($rawPermalink !== false ? $rawPermalink : null);
365 $status = get_post_status($pageid);
366 if (($permalink != false) &&
367 (in_array($status, array('publish', 'published')))) {
368 $homeURL = get_home_url();
369 if ($homeURL == null) {
370 $homeURL = '';
371 }
372 $urlHomeDirectory = parse_url($homeURL, PHP_URL_PATH);
373 if ($urlHomeDirectory == null) {
374 $urlHomeDirectory = '';
375 }
376 $urlHomeDirectory = rtrim($urlHomeDirectory, '/');
377 $fromURL = $urlHomeDirectory . '/?p=' . $pageid;
378 $redirect = $this->dao->getExistingRedirectForURL($fromURL);
379 $defaultRedirect = is_scalar($options['default_redirect']) ? (string)$options['default_redirect'] : '301';
380 if (!isset($redirect['id']) || $redirect['id'] == 0) {
381 $this->dao->setupRedirect($fromURL, (string)ABJ404_STATUS_AUTO, (string)ABJ404_TYPE_POST,
382 (string)$pageid, $defaultRedirect, 0, 'page ID');
383 }
384 $this->dao->logRedirectHit($fromURL, $permalink, 'page ID');
385 $this->forceRedirect($permalink, (int)$defaultRedirect);
386 exit;
387 }
388 }
389 }
390
391 /**
392 * @global type $abj404logging
393 * @global type $abj404logic
394 * @param string $urlRequest the requested URL. e.g. /404killer/aboutt
395 * @param string $urlSlugOnly only the slug. e.g. /aboutt
396 * @return void
397 */
398 function initializeIgnoreValues(string $urlRequest, string $urlSlugOnly): void {
399 $abj404logic = abj_service('plugin_logic');
400
401 $options = $abj404logic->getOptions();
402 $ignoreReasonDoNotProcess = null;
403 $ignoreReasonDoProcess = null;
404 $httpUserAgent = array_key_exists('HTTP_USER_AGENT', $_SERVER) ?
405 $this->f->strtolower($_SERVER['HTTP_USER_AGENT']) : '';
406
407 // Note: is_admin() does not mean the user is an admin - it returns true when the user is on an admin screen.
408 // ignore requests that are supposed to be for an admin.
409 $adminURLRaw = parse_url(admin_url(), PHP_URL_PATH);
410 $adminURL = is_string($adminURLRaw) ? $adminURLRaw : '/wp-admin/';
411 if (is_admin() || $this->f->substr($urlRequest, 0, $this->f->strlen($adminURL)) == $adminURL) {
412 $this->logger->debugMessage("Ignoring admin URL: " . $urlRequest);
413 $ignoreReasonDoNotProcess = 'Admin URL';
414 }
415
416 // The user agent Zemanta Aggregator http://www.zemanta.com causes a lot of false positives on
417 // posts that are still drafts and not actually published yet. It's from the plugin "WordPress Related Posts"
418 // by https://www.sovrn.com/.
419 $ignoreDontProcess = is_string($options['ignore_dontprocess']) ? $options['ignore_dontprocess'] : '';
420 $userAgents = $this->f->explodeNewline($ignoreDontProcess);
421
422 foreach ($userAgents as $agentToIgnore) {
423 if (stripos($httpUserAgent, trim($agentToIgnore)) !== false) {
424 $this->logger->debugMessage("Ignoring user agent (do not redirect): " .
425 esc_html($_SERVER['HTTP_USER_AGENT']) . " for URL: " . esc_html($urlRequest));
426 $ignoreReasonDoNotProcess = 'User agent (do not redirect): ' . esc_html($_SERVER['HTTP_USER_AGENT']);
427 }
428 }
429
430 // ----- ignore based on regex file path
431 $patternsToIgnore = is_array($options['folders_files_ignore_usable']) ? $options['folders_files_ignore_usable'] : array();
432 if (!empty($patternsToIgnore)) {
433 foreach ($patternsToIgnore as $patternToIgnore) {
434 $patternToIgnoreStr = is_string($patternToIgnore) ? $patternToIgnore : (string)$patternToIgnore;
435 $patternToIgnoreNoSlashes = stripslashes($patternToIgnoreStr);
436 abj_service('request_context')->debug_info = 'Applying regex pattern to ignore\"' .
437 $patternToIgnoreNoSlashes . '" to URL slug: ' . $urlSlugOnly;
438 $matches = array();
439 if ($this->f->regexMatch($patternToIgnoreNoSlashes, $urlSlugOnly, $matches)) {
440 $this->logger->debugMessage("Ignoring file/folder (do not redirect) for URL: " .
441 esc_html($urlSlugOnly) . ", pattern used: " . $patternToIgnoreNoSlashes);
442 $ignoreReasonDoNotProcess = 'Files and folders (do not redirect) pattern: ' .
443 esc_html($patternToIgnoreNoSlashes);
444 }
445 abj_service('request_context')->debug_info = 'Cleared after regex pattern to ignore.';
446 }
447 }
448 abj_service('request_context')->ignore_donotprocess = is_string($ignoreReasonDoNotProcess) ? $ignoreReasonDoNotProcess : false;
449
450 // -----
451 // ignore and process
452 $ignoreDoProcess = is_string($options['ignore_doprocess']) ? $options['ignore_doprocess'] : '';
453 $userAgents = $this->f->explodeNewline($ignoreDoProcess);
454
455 foreach ($userAgents as $agentToIgnore) {
456 if (stripos($httpUserAgent, trim($agentToIgnore)) !== false) {
457 $this->logger->debugMessage("Ignoring user agent (process ok): " .
458 esc_html($_SERVER['HTTP_USER_AGENT']) . " for URL: " . esc_html($urlRequest));
459 $ignoreReasonDoProcess = 'User agent (process ok): ' . $agentToIgnore;
460 }
461 }
462 abj_service('request_context')->ignore_doprocess = is_string($ignoreReasonDoProcess) ? $ignoreReasonDoProcess : false;
463 }
464
465 /** @return string */
466 function readCookieWithPreviousRqeuestShort(): string {
467 $cookieName = ABJ404_PP . '_REQUEST_URI';
468 $cookieNameShort = $cookieName . '_SHORT';
469
470 if (array_key_exists($cookieNameShort, $_COOKIE) &&
471 array_key_exists($cookieName, $_COOKIE)) {
472 return $_COOKIE[$cookieName];
473 }
474
475 return '';
476 }
477
478 /** Set a cookie with the requested URL (path only, no query string).
479 * Security: Query strings may contain sensitive data (tokens, auth codes, etc.)
480 * so we only store the path portion of the URL.
481 * @return void
482 */
483 function setCookieWithPreviousRequest(): void {
484
485 $requested_url_raw = $this->f->normalizeUrlString($_SERVER['REQUEST_URI']);
486
487 // Security: Strip query string to avoid storing sensitive params (tokens, auth codes, etc.)
488 $requested_url_cleaned = preg_replace('/\?.*$/', '', $requested_url_raw);
489 $requested_url = is_string($requested_url_cleaned) ? $requested_url_cleaned : $requested_url_raw;
490
491 // this may be used later when displaying suggestions.
492 $cookieName = ABJ404_PP . '_REQUEST_URI';
493 $cookieNameShort = $cookieName . '_SHORT';
494 try {
495 setcookie($cookieName, $requested_url, time() + (60 * 4), "/");
496 setcookie($cookieNameShort, $requested_url, time() + (5), "/");
497
498 // only set the update_URL if it's not already set.
499 // this is because multiple redirects might happen and we want to store
500 // only the user's original requested page.
501 if (!isset($_COOKIE[$cookieName . '_UPDATE_URL']) ||
502 empty($_COOKIE[$cookieName . '_UPDATE_URL'])) {
503 // Also strip query string from UPDATE_URL for consistency
504 $update_url_raw = $this->f->normalizeUrlString($_SERVER['REQUEST_URI']);
505 $update_url_cleaned = preg_replace('/\?.*$/', '', $update_url_raw);
506 $update_url = is_string($update_url_cleaned) ? $update_url_cleaned : $update_url_raw;
507 setcookie($cookieName . '_UPDATE_URL', $update_url,
508 time() + (60 * 4), "/");
509 }
510
511 } catch (Exception $e) {
512 $this->logger->debugMessage("There was an issue setting a cookie: " . $e->getMessage());
513 // This javascript redirect will only appear if the header redirect did not work for some reason.
514 // document.cookie = "username=John Doe; expires=Thu, 18 Dec 2013 12:00:00 UTC";
515 $expireTime = date("D, d M Y H:i:s T", time() + (60 * 4));
516 $c = "\n" . '<script>document.cookie = "' . $cookieName . '=' .
517 esc_js($requested_url) .
518 '; expires=' . $expireTime . '";</script>' . "\n";
519 echo $c;
520 }
521
522 abj_service('request_context')->requested_url = $requested_url;
523 }
524
525 /** The passed in reason will be appended to the automatically generated reason.
526 * @param string $requestedURL
527 * @param string $reason
528 * @param bool $useUserSpecified404
529 * @param array<string, mixed>|null $optionsOverride
530 * @return void
531 */
532 function sendTo404Page(string $requestedURL, string $reason = '', bool $useUserSpecified404 = true, $optionsOverride = null): void {
533 $abj404logic = abj_service('plugin_logic');
534
535 $options = (is_array($optionsOverride) ? $optionsOverride : $abj404logic->getOptions());
536
537 // ---------------------------------------
538 // Fallback detection: if behavior is 'suggest' but system page was deleted,
539 // flip to theme_default before attempting to redirect.
540 $behavior = isset($options['dest404_behavior']) ? $options['dest404_behavior'] : '';
541 if ($behavior === 'suggest') {
542 $systemPage = ABJ_404_Solution_SystemPage::getInstance();
543 if (!$systemPage->systemPageExists()) {
544 $systemPage->handleSystemPageDeleted();
545 // Reload options after flip
546 $options = $this->getOptions(true);
547 }
548 }
549
550 // if there's a default 404 page specified then use that.
551 $dest404pageRaw = isset($options['dest404page']) ? $options['dest404page'] : null;
552 $dest404page = is_string($dest404pageRaw) ? $dest404pageRaw : (ABJ404_TYPE_404_DISPLAYED . '|' . ABJ404_TYPE_404_DISPLAYED);
553
554 if ($useUserSpecified404 && $this->thereIsAUserSpecified404Page($dest404page)) {
555 // $idAndType OK on regular 404
556 $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($dest404page, 0,
557 null, $options);
558
559 // make sure the page exists
560 if (!in_array($permalink['status'], array('publish', 'published'))) {
561 $msg = __("The user specified 404 page wasn't found. " .
562 "Please update the user-specified 404 page on the Options page.",
563 '404-solution');
564 $this->logger->infoMessage($msg);
565
566 } else {
567 // dipslay the user specified 404 page.
568
569 // get the existing redirect before adding a new one.
570 $redirect = $this->dao->getExistingRedirectForURL($requestedURL);
571 $pType = is_scalar($permalink['type']) ? (string)$permalink['type'] : '';
572 $pId = is_scalar($permalink['id']) ? (string)$permalink['id'] : '';
573 $pLink = is_scalar($permalink['link']) ? (string)$permalink['link'] : '';
574 $defRedir = is_scalar($options['default_redirect']) ? (string)$options['default_redirect'] : '301';
575 if (!isset($redirect['id']) || $redirect['id'] == 0) {
576 $this->dao->setupRedirect($requestedURL, (string)ABJ404_STATUS_CAPTURED, $pType, $pId, $defRedir, 0);
577 }
578
579 $this->dao->logRedirectHit($requestedURL, $pLink, 'user specified 404 page. ' . $reason);
580
581 // set cookie here to remmeber to use a 404 status when displaying the 404 page
582 setcookie(ABJ404_PP . '_STATUS_404', 'true', time() + 20, "/");
583
584 // the 404 page...
585 $abj404logic->forceRedirect(esc_url($pLink),
586 (int)$defRedir);
587 exit;
588 }
589 }
590
591 // ---------------------------------------
592 // give up. log the 404.
593 if (@$options['capture_404'] == '1') {
594 // get the existing redirect before adding a new one.
595 $redirect = $this->dao->getExistingRedirectForURL($requestedURL);
596 $defRedir2 = is_scalar($options['default_redirect']) ? (string)$options['default_redirect'] : '301';
597 if (!isset($redirect['id']) || $redirect['id'] == 0) {
598 $this->dao->setupRedirect($requestedURL, (string)ABJ404_STATUS_CAPTURED, (string)ABJ404_TYPE_404_DISPLAYED, (string)ABJ404_TYPE_404_DISPLAYED, $defRedir2, 0);
599 }
600 } else {
601 $optionsJson = json_encode($options);
602 $this->logger->debugMessage("No permalink found to redirect to. capture_404 is off. Requested URL: " . $requestedURL .
603 " | Redirect: (none)" . " | is_single(): " . is_single() . " | " .
604 "is_page(): " . is_page() . " | is_feed(): " . is_feed() . " | is_trackback(): " .
605 is_trackback() . " | is_preview(): " . is_preview() . " | options: " . wp_kses_post(is_string($optionsJson) ? $optionsJson : ''));
606 }
607 }
608
609 /** Returns true if there is a custom 404 page.
610 * @param string|null $dest404page
611 * @return bool
612 */
613 function thereIsAUserSpecified404Page($dest404page): bool {
614 if ($dest404page == null) {
615 return false;
616 }
617 $check1 = ($dest404page !== (ABJ404_TYPE_404_DISPLAYED . '|' . ABJ404_TYPE_404_DISPLAYED));
618 $check2 = ($dest404page !== (string)ABJ404_TYPE_404_DISPLAYED);
619 return $check1 && $check2;
620 }
621
622 /**
623 * @param bool $skip_db_check
624 * @return array<string, mixed>
625 */
626 function getOptions(bool $skip_db_check = false) {
627 if (!$skip_db_check && is_array($this->resolvedOptionsWithDbCheck)) {
628 return $this->resolvedOptionsWithDbCheck;
629 }
630 if ($skip_db_check) {
631 if (is_array($this->resolvedOptionsSkipDbCheck)) {
632 return $this->resolvedOptionsSkipDbCheck;
633 }
634 // A full checked set is safe to reuse for skip-db-check callers.
635 if (is_array($this->resolvedOptionsWithDbCheck)) {
636 return $this->resolvedOptionsWithDbCheck;
637 }
638 }
639
640 if ($this->options == null) {
641 $optionResult = get_option('abj404_settings');
642 $this->options = is_array($optionResult) ? $optionResult : null;
643 }
644 $options = $this->options;
645
646 if (!is_array($options)) {
647 add_option('abj404_settings', '', '', false);
648 $options = array();
649 }
650
651 // Check to make sure we aren't missing any new options.
652 $defaults = $this->getDefaultOptions();
653 $missing = false;
654 foreach ($defaults as $key => $value) {
655 if (!isset($options[$key]) || $options[$key] === '') {
656 $options[$key] = $value;
657 $missing = true;
658 }
659 }
660
661 if ($missing) {
662 $this->updateOptions($options);
663 }
664
665 if ($skip_db_check == false) {
666 if (!array_key_exists('DB_VERSION', $options) || $options['DB_VERSION'] != ABJ404_VERSION) {
667 $options = $this->updateToNewVersion($options);
668 }
669 }
670
671 // Normalize suggestion templates so malformed placeholder values never leak to frontend.
672 if ($this->normalizeSuggestionTemplateOptions($options)) {
673 $this->updateOptions($options);
674 }
675
676 if ($skip_db_check) {
677 $this->resolvedOptionsSkipDbCheck = $options;
678 } else {
679 $this->resolvedOptionsWithDbCheck = $options;
680 }
681
682 return $options;
683 }
684
685 /** @param array<string, mixed> $options @return void */
686 function updateOptions(array $options): void {
687 $old_options = $this->options;
688 update_option('abj404_settings', $options);
689 $this->options = $options;
690 // The persistent options changed, so invalidate per-request resolved caches.
691 $this->resolvedOptionsSkipDbCheck = null;
692 $this->resolvedOptionsWithDbCheck = null;
693 }
694
695 /** Do any maintenance when upgrading to a new version.
696 * @global type $abj404logging
697 * @param array<string, mixed> $options
698 * @return array<string, mixed>
699 */
700 function updateToNewVersion(array $options) {
701 // Flush opcache for critical class files before any upgrade logic runs.
702 // On hosts with aggressive opcache (WP Engine, Flywheel, etc.) stale bytecode
703 // can persist after the plugin's PHP files are replaced on disk, causing
704 // transient fatals from class/method signature mismatches.
705 self::invalidateOpcacheForCriticalFiles();
706
707 $syncUtils = abj_service('sync_utils');
708
709 $synchronizedKeyFromUser = "update_db_version";
710 $uniqueID = $syncUtils->synchronizerAcquireLockTry($synchronizedKeyFromUser);
711
712 if ($uniqueID == '' || $uniqueID == null) {
713 $this->logger->debugMessage("Avoiding infinite loop on database update.");
714 return $options;
715 }
716
717 $returnValue = $options;
718
719 // Fixed: Use finally block to ensure lock is ALWAYS released, even on fatal errors
720 try {
721 $returnValue = $this->updateToNewVersionAction($options);
722
723 } catch (Throwable $e) { // Fixed: Catch Throwable (Exception + Error) instead of just Exception
724 $this->logger->errorMessage("Error updating to new version. ", $e instanceof \Exception ? $e : null);
725 throw $e; // Re-throw to propagate the error
726 } finally {
727 // This ALWAYS executes, even on fatal errors or exceptions
728 $syncUtils->synchronizerReleaseLock($uniqueID, $synchronizedKeyFromUser);
729 }
730
731 // update the permalink cache because updating the plugin version may affect it.
732 $permalinkCache = abj_service('permalink_cache');
733 $permalinkCache->updatePermalinkCache(1);
734
735 return $returnValue;
736 }
737
738 /** Do any maintenance when upgrading to a new version.
739 * @global type $abj404logic
740 * @global type $abj404logging
741 * @global type $wpdb
742 * @param array<string, mixed> $options
743 * @return array<string, mixed>
744 */
745 function updateToNewVersionAction(array $options) {
746 global $wpdb;
747
748 if (!is_array($options)) {
749 $options = array();
750 }
751 // Ensure all expected keys exist even when called with partial settings (tests/migrations).
752 $options = array_merge($this->getDefaultOptions(), $options);
753
754 $currentDBVersion = "(unknown)";
755 if (array_key_exists('DB_VERSION', $options) && is_string($options['DB_VERSION'])) {
756 $currentDBVersion = $options['DB_VERSION'];
757 }
758 $this->logger->infoMessage(self::$uniqID . ": Updating database version from " .
759 $currentDBVersion . " to " . ABJ404_VERSION . " (begin).");
760
761 // remove old log files. added in 2.28.0
762 $fileUtils = abj_service('functions');
763 $fileUtils->deleteDirectoryRecursively(ABJ404_PATH . 'temp/');
764
765 // wp_abj404_logsv2 exists since 1.7.
766 $upgradesEtc = abj_service('database_upgrades');
767 $upgradesEtc->createDatabaseTables(true);
768
769 // abj404_duplicateCronAction is no longer needed as of 1.7.
770 wp_clear_scheduled_hook('abj404_duplicateCronAction');
771
772 ABJ_404_Solution_PluginLogic::doUnregisterCrons();
773 // added in 1.8.2
774 ABJ_404_Solution_PluginLogic::doRegisterCrons();
775
776 // since 1.9.0. ignore_doprocess add SeznamBot, Pinterestbot, UptimeRobot and "Slurp" -> "Yahoo! Slurp"
777 if (version_compare($currentDBVersion, '1.9.0') < 0) {
778 $ignoreDoProcessStr = is_string($options['ignore_doprocess']) ? $options['ignore_doprocess'] : '';
779 $userAgents = $this->f->explodeNewline($ignoreDoProcessStr);
780
781 $uasForSearch = $this->f->explodeNewline($ignoreDoProcessStr);
782
783 foreach ($userAgents as &$str) {
784 if ($this->f->strtolower(trim($str)) == "slurp") {
785 $str = "Yahoo! Slurp";
786 $this->logger->infoMessage('Changed user agent "Slurp" to "Yahoo! Slurp" in the do not log list.');
787 }
788 }
789
790 if (!in_array("seznambot", $uasForSearch)) {
791 $userAgents[] = 'SeznamBot';
792 $this->logger->infoMessage('Added user agent "SeznamBot" to do not log list."');
793 }
794 if (!in_array("pinterestbot", $uasForSearch)) {
795 $userAgents[] = 'Pinterestbot';
796 $this->logger->infoMessage('Added user agent "Pinterestbot" to do not log list."');
797 }
798 if (!in_array("uptimerobot", $uasForSearch)) {
799 $userAgents[] = 'UptimeRobot';
800 $this->logger->infoMessage('Added user agent "UptimeRobot" to do not log list."');
801 }
802
803 $options['ignore_doprocess'] = implode("\n",$userAgents);
804 $this->updateOptions($options);
805 }
806
807 // move to the new log table
808 if (version_compare($currentDBVersion, '1.8.0') < 0) {
809 $query = "SHOW TABLES LIKE '{wp_abj404_logs}'";
810 $result = $this->dao->queryAndGetResults($query);
811 $rows = $result['rows'];
812
813 // make sure empty() only sees a variable and not a function for older PHP versions, due to
814 // https://stackoverflow.com/a/2173318 and
815 // https://wordpress.org/support/topic/fatal-error-will-latest-release/
816 $filteredRows = is_array($rows) ? array_filter($rows) : array();
817 if (!empty($filteredRows)) {
818 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/migrateToNewLogsTable.sql");
819 $query = $this->dao->doTableNameReplacements($query);
820 $result = $this->dao->queryAndGetResults($query);
821
822 // if anything was successfully imported then delete the old table.
823 if ($result['rows_affected'] > 0) {
824 $this->logger->infoMessage($result['rows_affected'] .
825 ' log rows were migrated to the new table structre.');
826 // log the rows inserted/migrated.
827 $this->dao->queryAndGetResults('drop table ' . $this->dao->getLowercasePrefix() . 'abj404_logs');
828 }
829 }
830 }
831
832 if (version_compare($currentDBVersion, '2.18.0') < 0) {
833 // add .well-known/acme-challenge/*, wp-content/themes/*, wp-content/plugins/* to folders_files_ignore
834 $foldersIgnoreStr = is_string($options['folders_files_ignore']) ? $options['folders_files_ignore'] : '';
835 $originalItems = $this->f->explodeNewline($foldersIgnoreStr);
836
837 $newItems = array("wp-content/plugins/*", "wp-content/themes/*", ".well-known/acme-challenge/*");
838 foreach ($newItems as $newItem) {
839 if (array_search($newItem, $originalItems) === false) {
840 $originalItems[] = $newItem;
841 $this->logger->infoMessage('Added ' . $newItem . ' to the list of folders to ignore."');
842 }
843 }
844
845 $options['folders_files_ignore'] = implode("\n",$originalItems);
846 $this->updateOptions($options);
847 }
848
849 // add the second part of the default destination page.
850 $dest404page = is_string($options['dest404page']) ? $options['dest404page'] : '';
851 if ($this->f->strpos($dest404page, '|') === false) {
852 // not found
853 if ($dest404page == '0') {
854 $dest404page .= "|" . ABJ404_TYPE_404_DISPLAYED;
855 } else {
856 $dest404page .= '|' . ABJ404_TYPE_POST;
857 }
858 $options['dest404page'] = $dest404page;
859 $this->updateOptions($options);
860 }
861
862 // Since 3.0.7: Mark existing users as having completed setup wizard
863 // This prevents the wizard from showing to users upgrading from earlier versions
864 // Important: Skip this for NEW installs (where DB_VERSION is 0.0.0) so they see the wizard
865 // @cache-write-audit: opt-out — stores a setup-completion date marker, not a query result
866 if ($currentDBVersion !== '0.0.0' && version_compare($currentDBVersion, '3.0.7') < 0) {
867 update_option('abj404_setup_completed', gmdate('Y-m-d'));
868 $this->logger->infoMessage('Marked setup wizard as completed for existing user.');
869 }
870
871 // Since 3.0.9: Migrate suggest_minscore to suggest_minscore_enabled checkbox
872 // If user had suggest_minscore set from an older version, enable the checkbox to preserve their behavior
873 if (!isset($options['suggest_minscore_enabled'])) {
874 if (isset($options['suggest_minscore']) && is_scalar($options['suggest_minscore']) && intval($options['suggest_minscore']) >= 25) {
875 $options['suggest_minscore_enabled'] = '1';
876 $this->logger->infoMessage('Enabled minimum score filtering based on existing suggest_minscore setting.');
877 } else {
878 $options['suggest_minscore_enabled'] = '0';
879 }
880 $this->updateOptions($options);
881 }
882
883 // Since 4.1.0: Migrate dest404page to dest404_behavior tile setting.
884 // Existing installs may have a custom page set. Map it to the new behavior.
885 if (!isset($options['dest404_behavior']) || $options['dest404_behavior'] === 'theme_default') {
886 $dest = is_string($options['dest404page']) ? $options['dest404page'] : '';
887 if ($dest === '0|' . ABJ404_TYPE_404_DISPLAYED || $dest === (string)ABJ404_TYPE_404_DISPLAYED || $dest === '') {
888 $options['dest404_behavior'] = 'theme_default';
889 } else if ($dest === '0|' . ABJ404_TYPE_HOME) {
890 $options['dest404_behavior'] = 'homepage';
891 } else if ($dest !== '') {
892 // Check if it's a system page (from a previous install of this feature)
893 $parts = explode('|', $dest);
894 $pageId = isset($parts[0]) ? (int)$parts[0] : 0;
895 if ($pageId > 0 && ABJ_404_Solution_SystemPage::isSystemPage($pageId)) {
896 $options['dest404_behavior'] = 'suggest';
897 } else {
898 $options['dest404_behavior'] = 'custom';
899 }
900 }
901 $this->updateOptions($options);
902 }
903
904 $options = $this->doUpdateDBVersionOption($options);
905 $this->logger->infoMessage(self::$uniqID . ": Updating database version to " .
906 ABJ404_VERSION . " (end).");
907
908 return $options;
909 }
910
911 /**
912 * @return array<string, mixed>
913 */
914 function getDefaultOptions() {
915 $options = array(
916 'default_redirect' => '301',
917 'send_error_logs' => '0',
918 'capture_404' => '1',
919 'capture_deletion' => 1095,
920 'manual_deletion' => '0',
921 'log_deletion' => '365',
922 'admin_notification' => '0',
923 'remove_matches' => '1',
924 'suggest_max' => '5',
925 'suggest_title' => '<h3>{suggest_title_text}</h3>',
926 'suggest_before' => '<ol>',
927 'suggest_after' => '</ol>',
928 'suggest_entrybefore' => '<li>',
929 'suggest_entryafter' => '</li>',
930 'suggest_noresults' => '<p>{suggest_noresults_text}</p>',
931 'suggest_cats' => '1',
932 'suggest_tags' => '1',
933 'suggest_minscore' => '25',
934 'suggest_minscore_enabled' => '0',
935 'update_suggest_url' => '0',
936 'auto_redirects' => '1',
937 'auto_slugs' => '1',
938 'auto_trash_redirect' => '0',
939 'auto_score' => '90',
940 'auto_score_title' => '',
941 'auto_score_category_tag' => '',
942 'auto_score_content' => '',
943 'template_redirect_priority' => '9',
944 'auto_deletion' => '1095',
945 'auto_302_expiration_days' => '0',
946 'auto_cats' => '1',
947 'auto_tags' => '1',
948 'dest404page' => '0|' . ABJ404_TYPE_404_DISPLAYED,
949 'maximum_log_disk_usage' => '10',
950 'ignore_dontprocess' => 'zemanta aggregator',
951 'ignore_doprocess' => "Googlebot\nMediapartners-Google\nAdsBot-Google\ndevelopers.google.com\n"
952 . "Bingbot\nYahoo! Slurp\nDuckDuckBot\nBaiduspider\nYandexBot\nwww.sogou.com\nSogou-Test-Spider\n"
953 . "Exabot\nfacebot\nfacebookexternalhit\nia_archiver\nSeznamBot\nPinterestbot\nUptimeRobot\nMJ12bot",
954 'recognized_post_types' => "page\npost\nproduct",
955 'recognized_categories' => "",
956 'folders_files_ignore' => implode("\n", array("wp-content/plugins/*", "wp-content/themes/*",
957 ".well-known/acme-challenge/*")),
958 'folders_files_ignore_usable' => "",
959 'suggest_regex_exclusions' => "",
960 'suggest_regex_exclusions_usable' => "",
961 'plugin_admin_users' => "",
962 'debug_mode' => 0,
963 'days_wait_before_major_update' => 30,
964 'DB_VERSION' => '0.0.0',
965 'menuLocation' => 'underSettings',
966 'admin_theme' => 'default',
967 'plugin_language_override' => '',
968 'disable_auto_dark_mode' => '0',
969 'admin_notification_email' => '',
970 'admin_notification_frequency' => 'instant',
971 'admin_notification_digest_limit' => '10',
972 'admin_notification_last_sent' => '0',
973 'page_redirects_order_by' => 'url',
974 'page_redirects_order' => 'ASC',
975 'captured_order_by' => 'logshits',
976 'captured_order' => 'DESC',
977 'excludePages[]' => '',
978 'dest404_behavior' => 'theme_default',
979 'auto_trash_junk_urls' => '1',
980 'auto_trash_junk_patterns' => implode("\n", array(
981 '.env', '.git/', '.aws/', '.svn/', '.hg/',
982 'xmlrpc.php', 'wlwmanifest.xml',
983 'wp-config', 'config.php', 'config.json', 'config.bak',
984 'phpinfo', 'phpmyadmin', 'phpMyAdmin', 'adminer',
985 'sqladmin', 'dbadmin', 'mysqladmin',
986 'id_rsa', '.bash_history', '.bashrc', '.DS_Store',
987 'nginx.conf', 'httpd.conf', 'Dockerfile', 'docker-compose',
988 '.sql', '.tar.gz', 'db_backup', 'database_backup',
989 'setup-config.php',
990 '/vendor/', '/node_modules/', '/tmp/',
991 '/_profiler/', '/_debugbar/', '/debug/', '/debugbar/',
992 '/META-INF/', '/WEB-INF/',
993 'magento_version', 'alfa-rex.php', 'bypass.php',
994 )),
995 );
996
997 return $options;
998 }
999
1000 /**
1001 * @param array<string, mixed>|null $options
1002 * @return array<string, mixed>
1003 */
1004 function doUpdateDBVersionOption($options = null): array {
1005 if ($options == null) {
1006 $options = $this->getOptions(true);
1007 }
1008
1009 $options['DB_VERSION'] = ABJ404_VERSION;
1010
1011 $this->updateOptions($options);
1012
1013 return $options;
1014 }
1015
1016 /**
1017 * Invalidate opcache entries for critical class files so that PHP loads
1018 * fresh bytecode after a plugin upgrade. Prevents transient fatals on
1019 * hosts with aggressive opcache settings (WP Engine, Flywheel, etc.).
1020 *
1021 * @return string[] File paths that were successfully invalidated.
1022 */
1023 static function invalidateOpcacheForCriticalFiles(): array {
1024 if (!function_exists('opcache_invalidate')) {
1025 return [];
1026 }
1027
1028 $files = [
1029 ABJ404_PATH . 'includes/Functions.php',
1030 ABJ404_PATH . 'includes/php/FunctionsMBString.php',
1031 ABJ404_PATH . 'includes/php/FunctionsPreg.php',
1032 ];
1033
1034 $invalidated = [];
1035 foreach ($files as $file) {
1036 if (is_file($file) && @opcache_invalidate($file, true)) {
1037 $invalidated[] = $file;
1038 }
1039 }
1040
1041 return $invalidated;
1042 }
1043
1044
1045 /** @return string */
1046 function getDebugLogFileLink(): string {
1047 return "?page=" . ABJ404_PP . "&subpage=abj404_debugfile";
1048 }
1049
1050 /** Get the "/commentpage" and the "?query=part" of the URL.
1051 * @return string */
1052 function getCommentPartAndQueryPartOfRequest() {
1053 // Fast path for common redirects: no query string and no comment-page segment.
1054 // This avoids UserRequest initialization/parsing for simple URLs.
1055 $requestUri = isset($_SERVER['REQUEST_URI']) ? (string)$_SERVER['REQUEST_URI'] : '';
1056 if ($requestUri !== '' &&
1057 strpos($requestUri, '?') === false &&
1058 strpos($requestUri, '/comment-page-') === false) {
1059 return '';
1060 }
1061
1062 $userRequest = ABJ_404_Solution_UserRequest::getInstance();
1063 if ($userRequest === null) {
1064 return '';
1065 }
1066 $queryString = $userRequest->getQueryString();
1067 $queryParts = $this->f->removePageIDFromQueryString(is_string($queryString) ? $queryString : '');
1068 $queryParts = ($queryParts == '') ? '' : '?' . $queryParts;
1069 $commentPart = $userRequest->getCommentPagePart();
1070 return (is_string($commentPart) ? $commentPart : '') . $queryParts;
1071 }
1072
1073 /** First try a wp_redirect. Then try a redirect with JavaScript. The wp_redirect usually works, but doesn't
1074 * if some other plugin has already output any kind of data.
1075 * @param string $location
1076 * @param int $status
1077 * @param int|string $type only 0 for sending to a 404 page
1078 * @param string $requestedURL
1079 * @param bool $isCustom404
1080 * @return bool true if the user is sent to the default 404 page.
1081 */
1082 function forceRedirect(string $location, int $status = 302, $type = -1, string $requestedURL = '', bool $isCustom404 = false): bool {
1083 // 410 Gone: send status header then render the gone410.html template and exit.
1084 if ($status === 410) {
1085 status_header(410);
1086 $templatePath = __DIR__ . '/html/gone410.html';
1087 if (file_exists($templatePath)) {
1088 $siteName = function_exists('get_bloginfo') ? get_bloginfo('name') : '';
1089 $siteUrl = function_exists('home_url') ? home_url('/') : '/';
1090 $templateContent = file_get_contents($templatePath);
1091 if (is_string($templateContent)) {
1092 $templateContent = str_replace(
1093 array('{site_name}', '{site_url}', '{heading}', '{message}', '{back_home}'),
1094 array(
1095 esc_html($siteName),
1096 esc_url($siteUrl),
1097 esc_html__('This content has been permanently removed.', '404-solution'),
1098 esc_html__('The page you requested no longer exists and has not been moved to a new location.', '404-solution'),
1099 esc_html__('Back to home page', '404-solution'),
1100 ),
1101 $templateContent
1102 );
1103 echo $templateContent;
1104 }
1105 }
1106 exit;
1107 }
1108
1109 // 451 Unavailable For Legal Reasons: send status header then render the gone451.html template and exit.
1110 if ($status === 451) {
1111 status_header(451);
1112 $templatePath = __DIR__ . '/html/gone451.html';
1113 if (file_exists($templatePath)) {
1114 $siteName = function_exists('get_bloginfo') ? get_bloginfo('name') : '';
1115 $siteUrl = function_exists('home_url') ? home_url('/') : '/';
1116 $templateContent = file_get_contents($templatePath);
1117 if (is_string($templateContent)) {
1118 $templateContent = str_replace(
1119 array('{site_name}', '{site_url}', '{heading}', '{message}', '{back_home}'),
1120 array(
1121 esc_html($siteName),
1122 esc_url($siteUrl),
1123 esc_html__('451 Unavailable For Legal Reasons', '404-solution'),
1124 esc_html__('This content is unavailable due to a legal demand.', '404-solution'),
1125 esc_html__('Back to home page', '404-solution'),
1126 ),
1127 $templateContent
1128 );
1129 echo $templateContent;
1130 }
1131 }
1132 exit;
1133 }
1134
1135 // Meta Refresh: emit an HTML page with <meta http-equiv="refresh"> and exit.
1136 if ($status === 0 && $location !== '') {
1137 status_header(200);
1138 $templatePath = __DIR__ . '/html/metaRefresh.html';
1139 if (file_exists($templatePath)) {
1140 $templateContent = file_get_contents($templatePath);
1141 if (is_string($templateContent)) {
1142 $templateContent = str_replace(
1143 array('{url}', '{delay}', '{title}', '{message}'),
1144 array(
1145 esc_url($location),
1146 '0',
1147 esc_html__('Redirecting…', '404-solution'),
1148 esc_html__('You are being redirected. Click the link if not redirected automatically.', '404-solution'),
1149 ),
1150 $templateContent
1151 );
1152 echo $templateContent;
1153 }
1154 }
1155 exit;
1156 }
1157
1158 $finalDestination = $this->buildFinalRedirectDestination($location, $requestedURL, $isCustom404);
1159
1160 $previousRequest = $this->readCookieWithPreviousRqeuestShort();
1161 $schemePos = $this->f->strpos($finalDestination, '://');
1162 $finalDestNoHome = ($schemePos !== false)
1163 ? $this->f->substr($finalDestination, $schemePos + 3) : $finalDestination;
1164 $slashPos = $this->f->strpos($finalDestNoHome, '/');
1165 $finalDestNoHome = ($slashPos !== false)
1166 ? $this->f->substr($finalDestNoHome, $slashPos) : '/';
1167
1168 $schemePos2 = $this->f->strpos($location, '://');
1169 $locationNoHome = ($schemePos2 !== false)
1170 ? $this->f->substr($location, $schemePos2 + 3) : $location;
1171 $slashPos2 = $this->f->strpos($locationNoHome, '/');
1172 $locationNoHome = ($slashPos2 !== false)
1173 ? $this->f->substr($locationNoHome, $slashPos2) : '/';
1174 // maybe avoid infinite redirects.
1175 if (!empty($previousRequest)) {
1176 if ($previousRequest == $finalDestNoHome && $previousRequest != $locationNoHome) {
1177 $this->logger->infoMessage("Maybe avoided infite redirects to/from: " .
1178 $previousRequest);
1179 $finalDestination = $location;
1180
1181 } else if ($previousRequest == $finalDestination) {
1182 $this->logger->infoMessage("Avoided infite redirects to/from: " .
1183 $previousRequest);
1184 return false;
1185 }
1186 }
1187
1188 // if the destination is the default 404 page then send the user there.
1189 if ($type == ABJ404_TYPE_404_DISPLAYED) {
1190 $abj404logic = abj_service('plugin_logic');
1191 $abj404logic->sendTo404Page($requestedURL, '', false);
1192
1193 return true;
1194 }
1195
1196 // try a normal redirect using a header.
1197 $this->setCookieWithPreviousRequest();
1198 // If headers can be sent, do a normal header redirect and exit immediately.
1199 // Only fall back to JS redirect when headers are already sent.
1200 if (!headers_sent()) {
1201 if (function_exists('abj404_benchmark_emit_headers')) {
1202 abj404_benchmark_emit_headers();
1203 }
1204 // Prefer wp_safe_redirect for same-host redirects to avoid header-injection edge cases,
1205 // but allow external redirects (plugin supports external redirect destinations).
1206 $useSafe = false;
1207 if (function_exists('wp_safe_redirect')) {
1208 $destHost = parse_url($finalDestination, PHP_URL_HOST);
1209 if ($destHost === null || $destHost === false || $destHost === '') {
1210 $useSafe = true; // relative URL
1211 } else {
1212 $homeHost = parse_url(home_url(), PHP_URL_HOST);
1213 if (is_string($homeHost) && $homeHost !== '' && strtolower($homeHost) === strtolower($destHost)) {
1214 $useSafe = true;
1215 }
1216 }
1217 }
1218
1219 if ($useSafe) {
1220 wp_safe_redirect($finalDestination, $status, ABJ404_NAME);
1221 } else {
1222 wp_redirect($finalDestination, $status, ABJ404_NAME);
1223 }
1224 if (defined('ABJ404_TEST_NO_EXIT') && ABJ404_TEST_NO_EXIT) {
1225 return false;
1226 }
1227 exit;
1228 }
1229
1230 // JS fallback redirect for the rare case some other plugin/theme already output content.
1231 // Use wp_json_encode to safely encode URL for JavaScript to prevent XSS.
1232 if (function_exists('abj404_benchmark_emit_headers')) {
1233 abj404_benchmark_emit_headers();
1234 }
1235 $c = '<script>' . 'function doRedirect() {' . "\n" .
1236 ' window.location.replace(' . wp_json_encode($finalDestination) . ');' . "\n" .
1237 '}' . "\n" .
1238 'setTimeout(doRedirect, 1);' . "\n" .
1239 '</script>' . "\n" .
1240 'Page moved: <a href="' . esc_url($finalDestination) . '">' .
1241 esc_html($finalDestination) . '</a>';
1242 echo $c;
1243 if (defined('ABJ404_TEST_NO_EXIT') && ABJ404_TEST_NO_EXIT) {
1244 return false;
1245 }
1246 exit;
1247 }
1248
1249 }
1250