PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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.19, at includes/PluginLogic.php

1,256 lines 53.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 /* 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 // Route the upgrade path through the canonical self-heal prologue so
768 // SelfHealingPrologueReachabilityTest can statically prove that
769 // upgrades reach the same recovery primitives as the daily cron.
770 // The prologue is idempotent and cheap when tables already exist;
771 // on installs missing a table it acts as a pre-upgrade repair pass.
772 $upgradesEtc->runSelfHealPrologue();
773 $upgradesEtc->createDatabaseTables(true);
774
775 // abj404_duplicateCronAction is no longer needed as of 1.7.
776 wp_clear_scheduled_hook('abj404_duplicateCronAction');
777
778 ABJ_404_Solution_PluginLogic::doUnregisterCrons();
779 // added in 1.8.2
780 ABJ_404_Solution_PluginLogic::doRegisterCrons();
781
782 // since 1.9.0. ignore_doprocess add SeznamBot, Pinterestbot, UptimeRobot and "Slurp" -> "Yahoo! Slurp"
783 if (version_compare($currentDBVersion, '1.9.0') < 0) {
784 $ignoreDoProcessStr = is_string($options['ignore_doprocess']) ? $options['ignore_doprocess'] : '';
785 $userAgents = $this->f->explodeNewline($ignoreDoProcessStr);
786
787 $uasForSearch = $this->f->explodeNewline($ignoreDoProcessStr);
788
789 foreach ($userAgents as &$str) {
790 if ($this->f->strtolower(trim($str)) == "slurp") {
791 $str = "Yahoo! Slurp";
792 $this->logger->infoMessage('Changed user agent "Slurp" to "Yahoo! Slurp" in the do not log list.');
793 }
794 }
795
796 if (!in_array("seznambot", $uasForSearch)) {
797 $userAgents[] = 'SeznamBot';
798 $this->logger->infoMessage('Added user agent "SeznamBot" to do not log list."');
799 }
800 if (!in_array("pinterestbot", $uasForSearch)) {
801 $userAgents[] = 'Pinterestbot';
802 $this->logger->infoMessage('Added user agent "Pinterestbot" to do not log list."');
803 }
804 if (!in_array("uptimerobot", $uasForSearch)) {
805 $userAgents[] = 'UptimeRobot';
806 $this->logger->infoMessage('Added user agent "UptimeRobot" to do not log list."');
807 }
808
809 $options['ignore_doprocess'] = implode("\n",$userAgents);
810 $this->updateOptions($options);
811 }
812
813 // move to the new log table
814 if (version_compare($currentDBVersion, '1.8.0') < 0) {
815 $query = "SHOW TABLES LIKE '{wp_abj404_logs}'";
816 $result = $this->dao->queryAndGetResults($query);
817 $rows = $result['rows'];
818
819 // make sure empty() only sees a variable and not a function for older PHP versions, due to
820 // https://stackoverflow.com/a/2173318 and
821 // https://wordpress.org/support/topic/fatal-error-will-latest-release/
822 $filteredRows = is_array($rows) ? array_filter($rows) : array();
823 if (!empty($filteredRows)) {
824 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/migrateToNewLogsTable.sql");
825 $query = $this->dao->doTableNameReplacements($query);
826 $result = $this->dao->queryAndGetResults($query);
827
828 // if anything was successfully imported then delete the old table.
829 if ($result['rows_affected'] > 0) {
830 $this->logger->infoMessage($result['rows_affected'] .
831 ' log rows were migrated to the new table structre.');
832 // log the rows inserted/migrated.
833 $this->dao->queryAndGetResults('drop table ' . $this->dao->getLowercasePrefix() . 'abj404_logs');
834 }
835 }
836 }
837
838 if (version_compare($currentDBVersion, '2.18.0') < 0) {
839 // add .well-known/acme-challenge/*, wp-content/themes/*, wp-content/plugins/* to folders_files_ignore
840 $foldersIgnoreStr = is_string($options['folders_files_ignore']) ? $options['folders_files_ignore'] : '';
841 $originalItems = $this->f->explodeNewline($foldersIgnoreStr);
842
843 $newItems = array("wp-content/plugins/*", "wp-content/themes/*", ".well-known/acme-challenge/*");
844 foreach ($newItems as $newItem) {
845 if (array_search($newItem, $originalItems) === false) {
846 $originalItems[] = $newItem;
847 $this->logger->infoMessage('Added ' . $newItem . ' to the list of folders to ignore."');
848 }
849 }
850
851 $options['folders_files_ignore'] = implode("\n",$originalItems);
852 $this->updateOptions($options);
853 }
854
855 // add the second part of the default destination page.
856 $dest404page = is_string($options['dest404page']) ? $options['dest404page'] : '';
857 if ($this->f->strpos($dest404page, '|') === false) {
858 // not found
859 if ($dest404page == '0') {
860 $dest404page .= "|" . ABJ404_TYPE_404_DISPLAYED;
861 } else {
862 $dest404page .= '|' . ABJ404_TYPE_POST;
863 }
864 $options['dest404page'] = $dest404page;
865 $this->updateOptions($options);
866 }
867
868 // Since 3.0.7: Mark existing users as having completed setup wizard
869 // This prevents the wizard from showing to users upgrading from earlier versions
870 // Important: Skip this for NEW installs (where DB_VERSION is 0.0.0) so they see the wizard
871 // @cache-write-audit: opt-out — stores a setup-completion date marker, not a query result
872 if ($currentDBVersion !== '0.0.0' && version_compare($currentDBVersion, '3.0.7') < 0) {
873 update_option('abj404_setup_completed', gmdate('Y-m-d'));
874 $this->logger->infoMessage('Marked setup wizard as completed for existing user.');
875 }
876
877 // Since 3.0.9: Migrate suggest_minscore to suggest_minscore_enabled checkbox
878 // If user had suggest_minscore set from an older version, enable the checkbox to preserve their behavior
879 if (!isset($options['suggest_minscore_enabled'])) {
880 if (isset($options['suggest_minscore']) && is_scalar($options['suggest_minscore']) && intval($options['suggest_minscore']) >= 25) {
881 $options['suggest_minscore_enabled'] = '1';
882 $this->logger->infoMessage('Enabled minimum score filtering based on existing suggest_minscore setting.');
883 } else {
884 $options['suggest_minscore_enabled'] = '0';
885 }
886 $this->updateOptions($options);
887 }
888
889 // Since 4.1.0: Migrate dest404page to dest404_behavior tile setting.
890 // Existing installs may have a custom page set. Map it to the new behavior.
891 if (!isset($options['dest404_behavior']) || $options['dest404_behavior'] === 'theme_default') {
892 $dest = is_string($options['dest404page']) ? $options['dest404page'] : '';
893 if ($dest === '0|' . ABJ404_TYPE_404_DISPLAYED || $dest === (string)ABJ404_TYPE_404_DISPLAYED || $dest === '') {
894 $options['dest404_behavior'] = 'theme_default';
895 } else if ($dest === '0|' . ABJ404_TYPE_HOME) {
896 $options['dest404_behavior'] = 'homepage';
897 } else if ($dest !== '') {
898 // Check if it's a system page (from a previous install of this feature)
899 $parts = explode('|', $dest);
900 $pageId = isset($parts[0]) ? (int)$parts[0] : 0;
901 if ($pageId > 0 && ABJ_404_Solution_SystemPage::isSystemPage($pageId)) {
902 $options['dest404_behavior'] = 'suggest';
903 } else {
904 $options['dest404_behavior'] = 'custom';
905 }
906 }
907 $this->updateOptions($options);
908 }
909
910 $options = $this->doUpdateDBVersionOption($options);
911 $this->logger->infoMessage(self::$uniqID . ": Updating database version to " .
912 ABJ404_VERSION . " (end).");
913
914 return $options;
915 }
916
917 /**
918 * @return array<string, mixed>
919 */
920 function getDefaultOptions() {
921 $options = array(
922 'default_redirect' => '301',
923 'send_error_logs' => '0',
924 'capture_404' => '1',
925 'capture_deletion' => 1095,
926 'manual_deletion' => '0',
927 'log_deletion' => '365',
928 'admin_notification' => '0',
929 'remove_matches' => '1',
930 'suggest_max' => '5',
931 'suggest_title' => '<h3>{suggest_title_text}</h3>',
932 'suggest_before' => '<ol>',
933 'suggest_after' => '</ol>',
934 'suggest_entrybefore' => '<li>',
935 'suggest_entryafter' => '</li>',
936 'suggest_noresults' => '<p>{suggest_noresults_text}</p>',
937 'suggest_cats' => '1',
938 'suggest_tags' => '1',
939 'suggest_minscore' => '25',
940 'suggest_minscore_enabled' => '0',
941 'update_suggest_url' => '0',
942 'auto_redirects' => '1',
943 'auto_slugs' => '1',
944 'auto_trash_redirect' => '0',
945 'auto_score' => '90',
946 'auto_score_title' => '',
947 'auto_score_category_tag' => '',
948 'auto_score_content' => '',
949 'template_redirect_priority' => '9',
950 'auto_deletion' => '1095',
951 'auto_302_expiration_days' => '0',
952 'auto_cats' => '1',
953 'auto_tags' => '1',
954 'dest404page' => '0|' . ABJ404_TYPE_404_DISPLAYED,
955 'maximum_log_disk_usage' => '10',
956 'ignore_dontprocess' => 'zemanta aggregator',
957 'ignore_doprocess' => "Googlebot\nMediapartners-Google\nAdsBot-Google\ndevelopers.google.com\n"
958 . "Bingbot\nYahoo! Slurp\nDuckDuckBot\nBaiduspider\nYandexBot\nwww.sogou.com\nSogou-Test-Spider\n"
959 . "Exabot\nfacebot\nfacebookexternalhit\nia_archiver\nSeznamBot\nPinterestbot\nUptimeRobot\nMJ12bot",
960 'recognized_post_types' => "page\npost\nproduct",
961 'recognized_categories' => "",
962 'folders_files_ignore' => implode("\n", array("wp-content/plugins/*", "wp-content/themes/*",
963 ".well-known/acme-challenge/*")),
964 'folders_files_ignore_usable' => "",
965 'suggest_regex_exclusions' => "",
966 'suggest_regex_exclusions_usable' => "",
967 'plugin_admin_users' => "",
968 'debug_mode' => 0,
969 'days_wait_before_major_update' => 30,
970 'DB_VERSION' => '0.0.0',
971 'menuLocation' => 'underSettings',
972 'admin_theme' => 'default',
973 'plugin_language_override' => '',
974 'disable_auto_dark_mode' => '0',
975 'admin_notification_email' => '',
976 'admin_notification_frequency' => 'instant',
977 'admin_notification_digest_limit' => '10',
978 'admin_notification_last_sent' => '0',
979 'page_redirects_order_by' => 'url',
980 'page_redirects_order' => 'ASC',
981 'captured_order_by' => 'logshits',
982 'captured_order' => 'DESC',
983 'excludePages[]' => '',
984 'dest404_behavior' => 'theme_default',
985 'auto_trash_junk_urls' => '1',
986 'auto_trash_junk_patterns' => implode("\n", array(
987 '.env', '.git/', '.aws/', '.svn/', '.hg/',
988 'xmlrpc.php', 'wlwmanifest.xml',
989 'wp-config', 'config.php', 'config.json', 'config.bak',
990 'phpinfo', 'phpmyadmin', 'phpMyAdmin', 'adminer',
991 'sqladmin', 'dbadmin', 'mysqladmin',
992 'id_rsa', '.bash_history', '.bashrc', '.DS_Store',
993 'nginx.conf', 'httpd.conf', 'Dockerfile', 'docker-compose',
994 '.sql', '.tar.gz', 'db_backup', 'database_backup',
995 'setup-config.php',
996 '/vendor/', '/node_modules/', '/tmp/',
997 '/_profiler/', '/_debugbar/', '/debug/', '/debugbar/',
998 '/META-INF/', '/WEB-INF/',
999 'magento_version', 'alfa-rex.php', 'bypass.php',
1000 )),
1001 );
1002
1003 return $options;
1004 }
1005
1006 /**
1007 * @param array<string, mixed>|null $options
1008 * @return array<string, mixed>
1009 */
1010 function doUpdateDBVersionOption($options = null): array {
1011 if ($options == null) {
1012 $options = $this->getOptions(true);
1013 }
1014
1015 $options['DB_VERSION'] = ABJ404_VERSION;
1016
1017 $this->updateOptions($options);
1018
1019 return $options;
1020 }
1021
1022 /**
1023 * Invalidate opcache entries for critical class files so that PHP loads
1024 * fresh bytecode after a plugin upgrade. Prevents transient fatals on
1025 * hosts with aggressive opcache settings (WP Engine, Flywheel, etc.).
1026 *
1027 * @return string[] File paths that were successfully invalidated.
1028 */
1029 static function invalidateOpcacheForCriticalFiles(): array {
1030 if (!function_exists('opcache_invalidate')) {
1031 return [];
1032 }
1033
1034 $files = [
1035 ABJ404_PATH . 'includes/Functions.php',
1036 ABJ404_PATH . 'includes/php/FunctionsMBString.php',
1037 ABJ404_PATH . 'includes/php/FunctionsPreg.php',
1038 ];
1039
1040 $invalidated = [];
1041 foreach ($files as $file) {
1042 if (is_file($file) && @opcache_invalidate($file, true)) {
1043 $invalidated[] = $file;
1044 }
1045 }
1046
1047 return $invalidated;
1048 }
1049
1050
1051 /** @return string */
1052 function getDebugLogFileLink(): string {
1053 return "?page=" . ABJ404_PP . "&subpage=abj404_debugfile";
1054 }
1055
1056 /** Get the "/commentpage" and the "?query=part" of the URL.
1057 * @return string */
1058 function getCommentPartAndQueryPartOfRequest() {
1059 // Fast path for common redirects: no query string and no comment-page segment.
1060 // This avoids UserRequest initialization/parsing for simple URLs.
1061 $requestUri = isset($_SERVER['REQUEST_URI']) ? (string)$_SERVER['REQUEST_URI'] : '';
1062 if ($requestUri !== '' &&
1063 strpos($requestUri, '?') === false &&
1064 strpos($requestUri, '/comment-page-') === false) {
1065 return '';
1066 }
1067
1068 $userRequest = ABJ_404_Solution_UserRequest::getInstance();
1069 if ($userRequest === null) {
1070 return '';
1071 }
1072 $queryString = $userRequest->getQueryString();
1073 $queryParts = $this->f->removePageIDFromQueryString(is_string($queryString) ? $queryString : '');
1074 $queryParts = ($queryParts == '') ? '' : '?' . $queryParts;
1075 $commentPart = $userRequest->getCommentPagePart();
1076 return (is_string($commentPart) ? $commentPart : '') . $queryParts;
1077 }
1078
1079 /** First try a wp_redirect. Then try a redirect with JavaScript. The wp_redirect usually works, but doesn't
1080 * if some other plugin has already output any kind of data.
1081 * @param string $location
1082 * @param int $status
1083 * @param int|string $type only 0 for sending to a 404 page
1084 * @param string $requestedURL
1085 * @param bool $isCustom404
1086 * @return bool true if the user is sent to the default 404 page.
1087 */
1088 function forceRedirect(string $location, int $status = 302, $type = -1, string $requestedURL = '', bool $isCustom404 = false): bool {
1089 // 410 Gone: send status header then render the gone410.html template and exit.
1090 if ($status === 410) {
1091 status_header(410);
1092 $templatePath = __DIR__ . '/html/gone410.html';
1093 if (file_exists($templatePath)) {
1094 $siteName = function_exists('get_bloginfo') ? get_bloginfo('name') : '';
1095 $siteUrl = function_exists('home_url') ? home_url('/') : '/';
1096 $templateContent = file_get_contents($templatePath);
1097 if (is_string($templateContent)) {
1098 $templateContent = str_replace(
1099 array('{site_name}', '{site_url}', '{heading}', '{message}', '{back_home}'),
1100 array(
1101 esc_html($siteName),
1102 esc_url($siteUrl),
1103 esc_html__('This content has been permanently removed.', '404-solution'),
1104 esc_html__('The page you requested no longer exists and has not been moved to a new location.', '404-solution'),
1105 esc_html__('Back to home page', '404-solution'),
1106 ),
1107 $templateContent
1108 );
1109 echo $templateContent;
1110 }
1111 }
1112 exit;
1113 }
1114
1115 // 451 Unavailable For Legal Reasons: send status header then render the gone451.html template and exit.
1116 if ($status === 451) {
1117 status_header(451);
1118 $templatePath = __DIR__ . '/html/gone451.html';
1119 if (file_exists($templatePath)) {
1120 $siteName = function_exists('get_bloginfo') ? get_bloginfo('name') : '';
1121 $siteUrl = function_exists('home_url') ? home_url('/') : '/';
1122 $templateContent = file_get_contents($templatePath);
1123 if (is_string($templateContent)) {
1124 $templateContent = str_replace(
1125 array('{site_name}', '{site_url}', '{heading}', '{message}', '{back_home}'),
1126 array(
1127 esc_html($siteName),
1128 esc_url($siteUrl),
1129 esc_html__('451 Unavailable For Legal Reasons', '404-solution'),
1130 esc_html__('This content is unavailable due to a legal demand.', '404-solution'),
1131 esc_html__('Back to home page', '404-solution'),
1132 ),
1133 $templateContent
1134 );
1135 echo $templateContent;
1136 }
1137 }
1138 exit;
1139 }
1140
1141 // Meta Refresh: emit an HTML page with <meta http-equiv="refresh"> and exit.
1142 if ($status === 0 && $location !== '') {
1143 status_header(200);
1144 $templatePath = __DIR__ . '/html/metaRefresh.html';
1145 if (file_exists($templatePath)) {
1146 $templateContent = file_get_contents($templatePath);
1147 if (is_string($templateContent)) {
1148 $templateContent = str_replace(
1149 array('{url}', '{delay}', '{title}', '{message}'),
1150 array(
1151 esc_url($location),
1152 '0',
1153 esc_html__('Redirecting…', '404-solution'),
1154 esc_html__('You are being redirected. Click the link if not redirected automatically.', '404-solution'),
1155 ),
1156 $templateContent
1157 );
1158 echo $templateContent;
1159 }
1160 }
1161 exit;
1162 }
1163
1164 $finalDestination = $this->buildFinalRedirectDestination($location, $requestedURL, $isCustom404);
1165
1166 $previousRequest = $this->readCookieWithPreviousRqeuestShort();
1167 $schemePos = $this->f->strpos($finalDestination, '://');
1168 $finalDestNoHome = ($schemePos !== false)
1169 ? $this->f->substr($finalDestination, $schemePos + 3) : $finalDestination;
1170 $slashPos = $this->f->strpos($finalDestNoHome, '/');
1171 $finalDestNoHome = ($slashPos !== false)
1172 ? $this->f->substr($finalDestNoHome, $slashPos) : '/';
1173
1174 $schemePos2 = $this->f->strpos($location, '://');
1175 $locationNoHome = ($schemePos2 !== false)
1176 ? $this->f->substr($location, $schemePos2 + 3) : $location;
1177 $slashPos2 = $this->f->strpos($locationNoHome, '/');
1178 $locationNoHome = ($slashPos2 !== false)
1179 ? $this->f->substr($locationNoHome, $slashPos2) : '/';
1180 // maybe avoid infinite redirects.
1181 if (!empty($previousRequest)) {
1182 if ($previousRequest == $finalDestNoHome && $previousRequest != $locationNoHome) {
1183 $this->logger->infoMessage("Maybe avoided infite redirects to/from: " .
1184 $previousRequest);
1185 $finalDestination = $location;
1186
1187 } else if ($previousRequest == $finalDestination) {
1188 $this->logger->infoMessage("Avoided infite redirects to/from: " .
1189 $previousRequest);
1190 return false;
1191 }
1192 }
1193
1194 // if the destination is the default 404 page then send the user there.
1195 if ($type == ABJ404_TYPE_404_DISPLAYED) {
1196 $abj404logic = abj_service('plugin_logic');
1197 $abj404logic->sendTo404Page($requestedURL, '', false);
1198
1199 return true;
1200 }
1201
1202 // try a normal redirect using a header.
1203 $this->setCookieWithPreviousRequest();
1204 // If headers can be sent, do a normal header redirect and exit immediately.
1205 // Only fall back to JS redirect when headers are already sent.
1206 if (!headers_sent()) {
1207 if (function_exists('abj404_benchmark_emit_headers')) {
1208 abj404_benchmark_emit_headers();
1209 }
1210 // Prefer wp_safe_redirect for same-host redirects to avoid header-injection edge cases,
1211 // but allow external redirects (plugin supports external redirect destinations).
1212 $useSafe = false;
1213 if (function_exists('wp_safe_redirect')) {
1214 $destHost = parse_url($finalDestination, PHP_URL_HOST);
1215 if ($destHost === null || $destHost === false || $destHost === '') {
1216 $useSafe = true; // relative URL
1217 } else {
1218 $homeHost = parse_url(home_url(), PHP_URL_HOST);
1219 if (is_string($homeHost) && $homeHost !== '' && strtolower($homeHost) === strtolower($destHost)) {
1220 $useSafe = true;
1221 }
1222 }
1223 }
1224
1225 if ($useSafe) {
1226 wp_safe_redirect($finalDestination, $status, ABJ404_NAME);
1227 } else {
1228 wp_redirect($finalDestination, $status, ABJ404_NAME);
1229 }
1230 if (!apply_filters('abj404_should_exit', true, array('source' => 'forceRedirect_header'))) {
1231 return false;
1232 }
1233 exit;
1234 }
1235
1236 // JS fallback redirect for the rare case some other plugin/theme already output content.
1237 // Use wp_json_encode to safely encode URL for JavaScript to prevent XSS.
1238 if (function_exists('abj404_benchmark_emit_headers')) {
1239 abj404_benchmark_emit_headers();
1240 }
1241 $c = '<script>' . 'function doRedirect() {' . "\n" .
1242 ' window.location.replace(' . wp_json_encode($finalDestination) . ');' . "\n" .
1243 '}' . "\n" .
1244 'setTimeout(doRedirect, 1);' . "\n" .
1245 '</script>' . "\n" .
1246 'Page moved: <a href="' . esc_url($finalDestination) . '">' .
1247 esc_html($finalDestination) . '</a>';
1248 echo $c;
1249 if (!apply_filters('abj404_should_exit', true, array('source' => 'forceRedirect_js'))) {
1250 return false;
1251 }
1252 exit;
1253 }
1254
1255 }
1256