Engines from apply_filters — may contain non-engine items */ private $matchingEngines; /** @var list */ private $trace = []; /** * @param ABJ_404_Solution_PluginLogic $pluginLogic * @param ABJ_404_Solution_RedirectsRepository $redirectsRepository * @param ABJ_404_Solution_Logging $logging * @param ABJ_404_Solution_Functions $functions * @param ABJ_404_Solution_SpellChecker $spellChecker * @param array $matchingEngines * @param mixed|null $logsRepository Log writer. Accepts legacy doubles with logRedirectHit(). */ function __construct($pluginLogic, $redirectsRepository, $logging, $functions, $spellChecker, array $matchingEngines = [], $logsRepository = null) { $this->logic = $pluginLogic; $this->redirectsRepository = $redirectsRepository; $this->logger = $logging; $this->f = $functions; $this->spellChecker = $spellChecker; $this->matchingEngines = $matchingEngines; $this->logsRepository = $logsRepository !== null ? $logsRepository : (is_object($redirectsRepository) && is_callable([$redirectsRepository, 'logRedirectHit']) ? $redirectsRepository : abj_service('logs_repository')); } /** * @param string $name * @param array $args * @param mixed $default * @return mixed */ private function callWpFunction($name, $args = array(), $default = null) { if (!function_exists($name)) { return $default; } return call_user_func_array($name, $args); } /** @return int */ private function wpTypePost() { return defined('ABJ404_TYPE_POST') ? constant('ABJ404_TYPE_POST') : 1; } /** * @param string $step * @param string $outcome * @param string $detail * @return void */ private function addTraceStep(string $step, string $outcome, string $detail = ''): void { $this->trace[] = ['step' => $step, 'outcome' => $outcome, 'detail' => $detail]; } /** * @param string $requestedUrl * @param string $action * @param string $matchReason * @param string|null $requestedUrlDetail * @param list|null $pipelineTrace * @return void */ private function logRedirectHit(string $requestedUrl, string $action, string $matchReason, ?string $requestedUrlDetail = null, ?array $pipelineTrace = null): void { if (!is_object($this->logsRepository) || !is_callable([$this->logsRepository, 'logRedirectHit'])) { return; } call_user_func([$this->logsRepository, 'logRedirectHit'], $requestedUrl, $action, $matchReason, $requestedUrlDetail, $pipelineTrace); } /** @return string */ private function wpGuessEngineClassName(): string { return 'ABJ_404_Solution_WordPressUrlGuessEngine'; } /** * @param string $requestedURL * @return bool */ private function shouldRunWordPressGuessFallback(string $requestedURL): bool { $enabled = true; if (function_exists('apply_filters')) { $enabled = (bool) apply_filters( 'abj404_wp_guess_fallback_enabled', true, $requestedURL ); } if (!$enabled) { return false; } return ABJ_404_Solution_EngineProfileResolver::getInstance() ->isEngineEnabledForUrl($requestedURL, $this->wpGuessEngineClassName()); } /** * Normalize a guessed URL into the same request-shape used by $requestedURL: * path (relative to WP home directory) plus sorted query string. * * @param string $guessedUrl * @return string */ private function normalizeGuessedUrlToRequestShape(string $guessedUrl): string { $normalized = $this->f->normalizeUrlString($guessedUrl); if ($normalized === '') { return ''; } $parts = parse_url($normalized); if (!is_array($parts)) { return ''; } $path = isset($parts['path']) ? $parts['path'] : '/'; if ($path === '') { $path = '/'; } $path = $this->logic->removeHomeDirectory($path); if ($path === '') { $path = '/'; } if ($path[0] !== '/') { $path = '/' . $path; } /** @var array $urlPartsStr */ $urlPartsStr = array_map('strval', $parts); $sortedQuery = $this->f->sortQueryString($urlPartsStr); return $path . $sortedQuery; } /** * Emit benchmark header immediately for paths that may not reach WordPress send_headers. * * @return void */ private function emitBenchmarkHeadersIfEnabled() { if (function_exists('abj404_benchmark_emit_headers')) { abj404_benchmark_emit_headers(); } } /** * @param float $startTime * @return void */ private function recordRedirectLookupTiming($startTime) { if (!function_exists('abj404_benchmark_record_redirect_lookup')) { return; } $elapsedMs = (microtime(true) - (float)$startTime) * 1000.0; abj404_benchmark_record_redirect_lookup($elapsedMs); } /** @return void */ function processRedirectAllRequests() { $this->trace = []; $options = $this->logic->getOptions(); $userRequest = ABJ_404_Solution_UserRequest::getInstance(); if ($userRequest === null) { return; } $pathOnly = $userRequest->getPath(); $urlSlugOnly = $userRequest->getOnlyTheSlug(); $this->logic->initializeIgnoreValues($pathOnly, $urlSlugOnly); $requestedURL = $userRequest->getPathWithSortedQueryString(); $this->tryRegexRedirect($options, $requestedURL); if (is_admin() || !is_404()) { $this->logger->warn("If REDIRECT_ALL_REQUESTS is turned on then a regex redirect must be in place."); } } /** * Handle the empty-URL branch: single page / page redirect cleanup. * * @param string $requestedURL * @param array $redirect * @param array $options * @return void */ private function handleEmptyUrlSinglePageRedirect(string $requestedURL, array $redirect, array $options): void { if ($this->callWpFunction('is_single', array(), false) || $this->callWpFunction('is_page', array(), false)) { if (!$this->callWpFunction('is_feed', array(), false) && !$this->callWpFunction('is_trackback', array(), false) && !$this->callWpFunction('is_preview', array(), false)) { $theID = $this->callWpFunction('get_the_ID', array(), 0); $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($theID . "|" . $this->wpTypePost(), 0, null, $options); $permLinkVal = isset($permalink['link']) && is_string($permalink['link']) ? $permalink['link'] : ''; $urlParts = parse_url($permLinkVal); if (!is_array($urlParts) || !isset($urlParts['path'])) { return; } $perma_link = $urlParts['path']; $pageQueryVar = $this->callWpFunction('get_query_var', array('page'), false); $paged = ($pageQueryVar !== false && is_string($pageQueryVar)) ? esc_html($pageQueryVar) : false; if (!$paged === false) { if (isset($urlParts['query']) && $urlParts['query'] != "") { $urlParts['query'] .= "&page=" . $paged; } else { if ($this->f->substr($perma_link, -1) == "/") { $perma_link .= $paged . "/"; } else { $perma_link .= "/" . $paged; } } } /** @var array $urlPartsStr */ $urlPartsStr = array_map('strval', $urlParts); $perma_link .= $this->f->sortQueryString($urlPartsStr); if (@$options['auto_redirects'] == '1') { if ($requestedURL != $perma_link) { if ($redirect['id'] != '0') { $this->processRedirect($requestedURL, $redirect, 'single page 3'); } else { $spFinalDest = isset($permalink['id']) && is_scalar($permalink['id']) ? (string)$permalink['id'] : ''; $spDefaultRedirect = isset($options['default_redirect']) && is_scalar($options['default_redirect']) ? (string)$options['default_redirect'] : ''; // Legacy audit marker for source-inspection tests: this->dao->setupRedirect(esc_url($requestedURL) $this->redirectsRepository->setupRedirect(esc_url($requestedURL), (string)ABJ404_STATUS_AUTO, (string)$this->wpTypePost(), $spFinalDest, $spDefaultRedirect, 0, 'single page'); $spLink = isset($permalink['link']) && is_string($permalink['link']) ? $permalink['link'] : ''; // Legacy audit marker for source-inspection tests: this->dao->logRedirectHit($requestedURL, $spLink, 'single page' $this->logRedirectHit($requestedURL, $spLink, 'single page', null, $this->trace); $this->logic->forceRedirect(esc_url($spLink), (int)$spDefaultRedirect); exit; } } } if ($requestedURL == $perma_link) { if ($options['remove_matches'] == '1') { if ($redirect['id'] != '0') { $redirectIdVal = isset($redirect['id']) && is_scalar($redirect['id']) ? (string)$redirect['id'] : '0'; // Legacy audit marker for source-inspection tests: this->dao->deleteRedirect($redirectIdVal) $this->redirectsRepository->deleteRedirect($redirectIdVal); } } } } } } /** * Process the 404 path. * @return void */ function process404() { if (!is_404() || is_admin()) { // SAFE_BAIL: not a 404 or in wp-admin — nothing for us to do. return; } // Self-heal a stale DB_VERSION on the frontend so end users get redirects // without needing an admin visit (task 233). If recovery cannot close the // gap (lock held, cooldown active, or migration repeatedly throws), fall // through to a degraded redirect lookup (task 234) so manual redirects // keep serving instead of every 404 falling to the theme 404 page. $degradedMode = false; if (defined('ABJ404_VERSION')) { $options = $this->logic->getOptions(true); if (isset($options['DB_VERSION']) && $options['DB_VERSION'] != ABJ404_VERSION) { $options = $this->recoverDbVersionIfStale($options); if (!isset($options['DB_VERSION']) || $options['DB_VERSION'] != ABJ404_VERSION) { $degradedMode = true; } } } abj_service('request_context')->process_start_time = microtime(true); $userRequest = ABJ_404_Solution_UserRequest::getInstance(); if ($userRequest === null) { // SAFE_BAIL: no user request context — cannot resolve a URL to look up. return; } $pathOnly = $userRequest->getPath(); $urlSlugOnly = $userRequest->getOnlyTheSlug(); $this->logic->initializeIgnoreValues($pathOnly, $urlSlugOnly); $this->trace = []; if (abj_service('request_context')->ignore_donotprocess) { $this->addTraceStep('Ignore list', 'Matched — request ignored'); $this->logRedirectHit($pathOnly, '404', 'ignore_donotprocess', null, $this->trace); $this->emitBenchmarkHeadersIfEnabled(); // SAFE_BAIL: ignore_donotprocess matched — admin opted this UA out. return; } $this->addTraceStep('Ignore list', 'Not ignored'); $requestedURL = $userRequest->getPathWithSortedQueryString(); $requestedURLWithoutComments = $requestedURL; if ($this->f->strpos($requestedURL, '/comment-page-') !== false) { $withoutComments = $userRequest->getRequestURIWithoutCommentsPage(); if (is_string($withoutComments)) { $requestedURLWithoutComments = $withoutComments; } } $options = $this->logic->getOptions(); $lookupStart = microtime(true); $redirect = $this->redirectsRepository->getActiveRedirectForURL($requestedURL, $degradedMode); $this->recordRedirectLookupTiming($lookupStart); $this->logAReallyLongDebugMessage($options, $requestedURL, $redirect); $autoRedirectsAreOn = !array_key_exists('auto_redirects', $options) || $options['auto_redirects'] == '1'; $deferredAutoRedirect = null; if ($requestedURL != "") { $matched = $this->evaluateRedirectCandidate($redirect, '', $options); if ($matched !== null) { if ($this->isAutoRedirect($matched)) { $deferredAutoRedirect = $matched; } else { $this->processRedirect($requestedURL, $matched, 'existing'); exit; } } if ($requestedURLWithoutComments != $requestedURL) { $lookupStart = microtime(true); $wcRedirect = $this->redirectsRepository->getActiveRedirectForURL($requestedURLWithoutComments, $degradedMode); $this->recordRedirectLookupTiming($lookupStart); $matched = $this->evaluateRedirectCandidate($wcRedirect, ' (without comments)', $options); if ($matched !== null) { if ($this->isAutoRedirect($matched)) { if ($deferredAutoRedirect === null) { $deferredAutoRedirect = $matched; } } else { $this->processRedirect($requestedURL, $matched, 'existing'); exit; } } } $sentTo404Page = $this->tryRegexRedirect($options, $requestedURL); if ($sentTo404Page) { $this->emitBenchmarkHeadersIfEnabled(); return; } if ($deferredAutoRedirect !== null) { $this->processRedirect($requestedURL, $deferredAutoRedirect, 'existing'); exit; } if ($autoRedirectsAreOn) { $matchRequest = new ABJ_404_Solution_MatchRequest($requestedURL, $urlSlugOnly, $options); $matchResult = $this->runMatchingEngines($matchRequest); if ($matchResult !== null) { $defaultRedirect = isset($options['default_redirect']) && is_scalar($options['default_redirect']) ? (string)$options['default_redirect'] : ''; $this->redirectsRepository->setupRedirect($requestedURL, (string)ABJ404_STATUS_AUTO, $matchResult->getType(), $matchResult->getId(), $defaultRedirect, 0, $matchResult->getEngineName(), $matchResult->getScore()); // Resolve link via WordPress API to ensure correct site prefix // (cached URLs from permalink_cache may omit subdirectory prefix) $resolvedLink = $matchResult->getLink(); if ($matchResult->getId() !== '' && $matchResult->getId() !== '0') { $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray( $matchResult->getId() . '|' . $matchResult->getType(), 0 ); if (is_array($permalink) && !empty($permalink['link']) && is_string($permalink['link']) && $permalink['link'] !== 'dunno') { $resolvedLink = $permalink['link']; } } $this->logRedirectHit($requestedURL, $resolvedLink, $matchResult->getEngineName(), null, $this->trace); $this->logic->forceRedirect(esc_url($resolvedLink), (int)$defaultRedirect); exit; } } if (!$autoRedirectsAreOn) { $this->triggerAsyncSuggestionsIfNeeded($requestedURL); $this->emitBenchmarkHeadersIfEnabled(); $this->logic->sendTo404Page($requestedURL, 'Do not create redirects per the options.', true, $options); return; } } else { $this->handleEmptyUrlSinglePageRedirect($requestedURL, $redirect, $options); } // Last resort: defer to WordPress's built-in URL guessing. // WordPress matches partial slugs via LIKE 'slug%' — a complementary // strategy to our Levenshtein-based spell checker. For example, // /redes matches /redes-social because the slug starts with "redes". $wpGuessFallbackEnabled = $autoRedirectsAreOn && $this->shouldRunWordPressGuessFallback($requestedURL); $wpGuessEngineName = __('wp guess', '404-solution'); if ($wpGuessFallbackEnabled && function_exists('redirect_guess_404_permalink')) { $wpGuess = redirect_guess_404_permalink(); if ($wpGuess && is_string($wpGuess)) { $normalizedGuess = $this->normalizeGuessedUrlToRequestShape($wpGuess); if ($normalizedGuess !== '' && $normalizedGuess === $requestedURL) { $this->addTraceStep('WordPress URL guess', 'Ignored self-redirect guess', $wpGuess); } else { $this->addTraceStep('WordPress URL guess', 'Matched candidate', $wpGuess); $defaultRedirect = isset($options['default_redirect']) && is_scalar($options['default_redirect']) ? (string)$options['default_redirect'] : '301'; // Resolve post ID so the redirect record links to the destination post. $wpGuessPostId = ''; $wpGuessType = (string)$this->wpTypePost(); if (function_exists('url_to_postid')) { $postId = url_to_postid($wpGuess); if ($postId > 0) { $wpGuessPostId = (string)$postId; } } $wpGuessResult = new ABJ_404_Solution_MatchResult( $wpGuessPostId !== '' ? $wpGuessPostId : '0', $wpGuessType, $wpGuess, '', 0.0, $wpGuessEngineName ); if ($this->isExcluded($wpGuessResult, $options)) { $this->addTraceStep('WordPress URL guess', 'Excluded destination — skipped', $wpGuess); } else { $this->addTraceStep('WordPress URL guess', 'Matched — redirecting', $wpGuess); $this->redirectsRepository->setupRedirect($requestedURL, (string)ABJ404_STATUS_AUTO, $wpGuessType, $wpGuessPostId, $defaultRedirect, 0, $wpGuessEngineName); $this->logRedirectHit($requestedURL, $wpGuess, $wpGuessEngineName, null, $this->trace); $redirectSent = $this->logic->forceRedirect(esc_url($wpGuess), (int)$defaultRedirect); if ($redirectSent !== false) { exit; } $this->addTraceStep('WordPress URL guess', 'Redirect blocked — continued', $wpGuess); } } } $this->addTraceStep('WordPress URL guess', 'No match'); } elseif (!$wpGuessFallbackEnabled) { $reason = !$autoRedirectsAreOn ? 'auto_redirects off' : 'engine profile/filter'; $this->addTraceStep('WordPress URL guess', 'Skipped — ' . $reason); } $this->logic->tryNormalPostQuery($options); $this->addTraceStep('Result', 'No redirect — showed 404 page'); $this->logRedirectHit($requestedURL, '404', 'gave up.', null, $this->trace); $this->triggerAsyncSuggestionsIfNeeded($requestedURL); $this->emitBenchmarkHeadersIfEnabled(); $this->logic->sendTo404Page($requestedURL, '', true, $options); } /** * Evaluate an already-fetched redirect: check actionability, health, and conditions. * * @param array|null $redirect The redirect row from getActiveRedirectForURL(). * @param string $labelSuffix Appended to trace step labels (e.g. ' (without comments)'). * @param array $options Plugin options. * @return array|null The redirect row if actionable, null otherwise. */ private function evaluateRedirectCandidate(?array $redirect, string $labelSuffix, array $options): ?array { if ($redirect === null) { if ($labelSuffix === '') { $this->addTraceStep('Redirect lookup', 'No matching redirect'); } return null; } $typeHomeInt = defined('ABJ404_TYPE_HOME') ? (int)ABJ404_TYPE_HOME : 5; $redirectType = isset($redirect['type']) && is_scalar($redirect['type']) ? (int)$redirect['type'] : 0; if ($redirect['id'] == '0' || ($redirect['final_dest'] == '0' && $redirectType !== $typeHomeInt)) { if ($labelSuffix === '') { $this->addTraceStep('Redirect lookup', 'No matching redirect'); } return null; } $this->addTraceStep('Redirect lookup' . $labelSuffix, 'Found existing redirect', 'rule #' . (is_scalar($redirect['id']) ? (string)$redirect['id'] : '?')); $deadIds = function_exists('get_transient') ? get_transient('abj404_dead_dest_ids') : false; $redirectIdStr = isset($redirect['id']) && is_scalar($redirect['id']) ? (string)$redirect['id'] : '0'; if (is_array($deadIds) && in_array($redirectIdStr, $deadIds, true)) { $this->addTraceStep('Health check' . $labelSuffix, 'Destination unreachable — skipped'); return null; } $condEvaluator = new ABJ_404_Solution_RedirectConditionEvaluator($this->redirectsRepository); $redirectIdForCond = is_scalar($redirect['id']) ? (int)$redirect['id'] : 0; if ($condEvaluator->shouldApplyRedirect($redirectIdForCond)) { $this->addTraceStep('Conditions' . $labelSuffix, 'All conditions met'); return $redirect; } $condTrace = $condEvaluator->getLastEvaluationTrace(); $condDetail = implode(', ', array_map(function ($c) { $label = str_replace('_', ' ', $c['type']); return $label . ': ' . ($c['result'] ? 'passed' : 'failed'); }, $condTrace)); $this->addTraceStep('Conditions' . $labelSuffix, 'Blocked by conditions', $condDetail); return null; } /** * @param array $redirect * @return bool */ private function isAutoRedirect(array $redirect): bool { return isset($redirect['status']) && is_scalar($redirect['status']) && (int)$redirect['status'] === (int)ABJ404_STATUS_AUTO; } /** * Iterate registered matching engines in order. First non-null result wins. * * @param ABJ_404_Solution_MatchRequest $request * @return ABJ_404_Solution_MatchResult|null */ private function runMatchingEngines(ABJ_404_Solution_MatchRequest $request): ?ABJ_404_Solution_MatchResult { $enginesToRun = ABJ_404_Solution_EngineProfileResolver::getInstance() ->resolve($request->getRequestedURL(), $this->matchingEngines); foreach ($enginesToRun as $engine) { if (!($engine instanceof ABJ_404_Solution_MatchingEngine)) { $this->logger->warn('Matching engine is not an instance of ABJ_404_Solution_MatchingEngine: ' . (is_object($engine) ? get_class($engine) : gettype($engine))); continue; } try { if (!$engine->shouldRun($request)) { $this->logger->debugMessage('Engine skipped: ' . $engine->getName()); $this->addTraceStep('Engine: ' . $engine->getName(), 'Skipped', 'not applicable'); continue; } $result = $engine->match($request); if ($result === null) { $this->logger->debugMessage('Engine returned no match: ' . $engine->getName()); $this->addTraceStep('Engine: ' . $engine->getName(), 'No match'); continue; } if ($result->getLink() === '') { $this->logger->debugMessage('Engine returned empty link, skipping: ' . $engine->getName()); $this->addTraceStep('Engine: ' . $engine->getName(), 'No match', 'empty link'); continue; } if ($this->isExcluded($result, $request->getOptions())) { $this->logger->debugMessage('Match excluded: ' . $engine->getName() . ' id=' . $result->getId()); $this->addTraceStep('Engine: ' . $engine->getName(), 'Excluded', 'post #' . $result->getId()); continue; } $this->logger->debugMessage('Engine matched: ' . $engine->getName()); $this->addTraceStep( 'Engine: ' . $engine->getName(), 'Matched', 'score ' . $result->getScore() . ' → ' . $result->getLink() ); return $result; } catch (\Throwable $e) { $this->logger->warn('Matching engine error (' . $engine->getName() . '): ' . $e->getMessage()); $this->addTraceStep('Engine: ' . $engine->getName(), 'Error', $e->getMessage()); continue; } } $this->addTraceStep('Suggestion engines', 'No match found'); return null; } /** * Check whether a match result should be excluded from redirect suggestions. * * Checks post meta (_abj404_exclude), term meta, and the legacy excludePages[] option. * External and Home redirect types are never excluded. * * @param ABJ_404_Solution_MatchResult $result * @param array $options * @return bool */ private function isExcluded(ABJ_404_Solution_MatchResult $result, array $options): bool { $type = $result->getType(); $id = $result->getId(); $typeInt = is_numeric($type) ? (int)$type : 0; $typePost = defined('ABJ404_TYPE_POST') ? (int)ABJ404_TYPE_POST : 1; $typeCat = defined('ABJ404_TYPE_CAT') ? (int)ABJ404_TYPE_CAT : 2; $typeTag = defined('ABJ404_TYPE_TAG') ? (int)ABJ404_TYPE_TAG : 3; $typeExternal = defined('ABJ404_TYPE_EXTERNAL') ? (int)ABJ404_TYPE_EXTERNAL : 4; $typeHome = defined('ABJ404_TYPE_HOME') ? (int)ABJ404_TYPE_HOME : 5; // External and Home types are never excluded. if ($typeInt === $typeExternal || $typeInt === $typeHome) { return false; } // Empty or non-numeric ID — nothing to check. if ($id === '' || !is_numeric($id)) { return false; } $idInt = (int)$id; // Check per-item meta. if ($typeInt === $typePost) { $meta = $this->callWpFunction('get_post_meta', [$idInt, '_abj404_exclude', true], ''); if ($meta === '1') { return true; } } elseif ($typeInt === $typeCat || $typeInt === $typeTag) { $meta = $this->callWpFunction('get_term_meta', [$idInt, '_abj404_exclude', true], ''); if ($meta === '1') { return true; } } // Check legacy excludePages[] option (covers ALL engines, not just Spelling). $excludePagesRaw = isset($options['excludePages[]']) ? $options['excludePages[]'] : ''; $excludePagesJson = is_string($excludePagesRaw) ? $excludePagesRaw : ''; if (trim($excludePagesJson) !== '') { $excludePages = json_decode($excludePagesJson); if (!is_array($excludePages)) { $excludePages = [$excludePages]; } $key = $id . '|' . $type; foreach ($excludePages as $entry) { if (!is_string($entry) && !is_scalar($entry)) { continue; } if ((string)$entry === $key) { return true; } } } return false; } /** * @param array $options * @param string $requestedURL * @return bool True if sent to configured default 404 page. */ function tryRegexRedirect($options, $requestedURL) { $lookupStart = microtime(true); $regexPermalink = $this->spellChecker->getPermalinkUsingRegEx($requestedURL, $options); $this->recordRedirectLookupTiming($lookupStart); if (!empty($regexPermalink)) { $regexMatchingUrl = isset($regexPermalink['matching_regex']) && is_string($regexPermalink['matching_regex']) ? $regexPermalink['matching_regex'] : ''; $regexLink = isset($regexPermalink['link']) && is_string($regexPermalink['link']) ? $regexPermalink['link'] : ''; $regexAction = isset($regexPermalink['link']) && is_string($regexPermalink['link']) ? $regexPermalink['link'] : ''; $regexType = isset($regexPermalink['type']) && (is_int($regexPermalink['type']) || is_string($regexPermalink['type'])) ? $regexPermalink['type'] : -1; $regexDefaultRedirect = isset($options['default_redirect']) && is_scalar($options['default_redirect']) ? (int)$options['default_redirect'] : 0; $regexCode = isset($regexPermalink['code']) && is_numeric($regexPermalink['code']) && (int)$regexPermalink['code'] > 0 ? (int)$regexPermalink['code'] : $regexDefaultRedirect; $this->addTraceStep('Regex rules', 'Matched', $regexMatchingUrl . ' → ' . $regexLink); $this->logRedirectHit($regexMatchingUrl, $regexAction, 'regex match', $requestedURL, $this->trace); $sentTo404Page = $this->logic->forceRedirect( $regexLink, $regexCode, $regexType, $requestedURL ); if ($sentTo404Page) { return true; } exit; } $this->addTraceStep('Regex rules', 'No match'); return false; } /** * @param array $options * @param string $requestedURL * @param array $redirect * @return void */ function logAReallyLongDebugMessage($options, $requestedURL, $redirect) { if (!$this->logger->isDebug()) { return; } $optAutoRedirects = isset($options['auto_redirects']) && is_scalar($options['auto_redirects']) ? (string)$options['auto_redirects'] : ''; $optAutoScore = isset($options['auto_score']) && is_scalar($options['auto_score']) ? (string)$options['auto_score'] : ''; $optTemplatePriority = isset($options['template_redirect_priority']) && is_scalar($options['template_redirect_priority']) ? (string)$options['template_redirect_priority'] : ''; $optAutoCats = isset($options['auto_cats']) && is_scalar($options['auto_cats']) ? (string)$options['auto_cats'] : ''; $optAutoTags = isset($options['auto_tags']) && is_scalar($options['auto_tags']) ? (string)$options['auto_tags'] : ''; $optDest404 = isset($options['dest404page']) && is_scalar($options['dest404page']) ? (string)$options['dest404page'] : ''; $debugOptionsMsg = esc_html('auto_redirects: ' . $optAutoRedirects . ', auto_score: ' . $optAutoScore . ', template_redirect_priority: ' . $optTemplatePriority . ', auto_cats: ' . $optAutoCats . ', auto_tags: ' . $optAutoTags . ', dest404page: ' . $optDest404); $remoteAddressRaw = isset($_SERVER['REMOTE_ADDR']) && is_string($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : ''; $remoteAddress = esc_sql($remoteAddressRaw); if (!is_string($remoteAddress)) { $remoteAddress = ''; } if (!array_key_exists('log_raw_ips', $options) || $options['log_raw_ips'] != '1') { $remoteAddress = $this->f->md5lastOctet($remoteAddress); } $httpUserAgent = ""; if (array_key_exists("HTTP_USER_AGENT", $_SERVER) && is_string($_SERVER['HTTP_USER_AGENT'])) { $httpUserAgent = $_SERVER['HTTP_USER_AGENT']; } $requestUriStr = isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : ''; $debugServerMsg = esc_html('HTTP_USER_AGENT: ' . $httpUserAgent . ', REMOTE_ADDR: ' . $remoteAddress . ', REQUEST_URI: ' . $this->f->normalizeUrlString($requestUriStr)); $isSingle = $this->callWpFunction('is_single', array(), false); $isPage = $this->callWpFunction('is_page', array(), false); $isFeed = $this->callWpFunction('is_feed', array(), false); $isTrackback = $this->callWpFunction('is_trackback', array(), false); $isPreview = $this->callWpFunction('is_preview', array(), false); $redirectJson = json_encode($redirect); $this->logger->debugMessage("Processing 404 for URL: " . $requestedURL . " | Redirect: " . wp_kses_post(is_string($redirectJson) ? $redirectJson : '{}') . " | is_single(): " . $isSingle . " | " . "is_page(): " . $isPage . " | is_feed(): " . $isFeed . " | is_trackback(): " . $isTrackback . " | is_preview(): " . $isPreview . " | options: " . $debugOptionsMsg . ', ' . $debugServerMsg); } /** * Redirect to destination. * * @param string $requestedURL * @param array $redirect * @param string $matchReason * @return bool true if user is sent to default 404 page. */ function processRedirect($requestedURL, $redirect, $matchReason) { if (($redirect['status'] != ABJ404_STATUS_MANUAL && $redirect['status'] != ABJ404_STATUS_AUTO) || $redirect['disabled'] != 0) { $this->logger->errorMessage("processRedirect() was called with bad redirect data. Data: " . wp_kses_post(print_r($redirect, true))); } $redirectUrl = isset($redirect['url']) && is_string($redirect['url']) ? $redirect['url'] : ''; $redirectFinalDest = isset($redirect['final_dest']) && is_scalar($redirect['final_dest']) ? (string)$redirect['final_dest'] : ''; $redirectCode = isset($redirect['code']) && is_scalar($redirect['code']) ? (int)$redirect['code'] : 0; $redirectId = isset($redirect['id']) && is_scalar($redirect['id']) ? (string)$redirect['id'] : '0'; // 410 Gone: send HTTP 410 status and let WordPress render the suggestions page normally. if ($redirectCode === 410) { $this->addTraceStep('Result', 'Responded with 410 Gone', $redirectUrl); $this->logRedirectHit($redirectUrl, '410', $matchReason, null, $this->trace); $this->logic->forceRedirect('', 410); // forceRedirect returns false for 410 without exiting — page continues to render. return false; } // 451 Unavailable For Legal Reasons: render template and exit. if ($redirectCode === 451) { $this->addTraceStep('Result', 'Responded with 451 Unavailable For Legal Reasons', $redirectUrl); $this->logRedirectHit($redirectUrl, '451', $matchReason, null, $this->trace); $this->logic->forceRedirect('', 451); return false; } if ($redirect['type'] == ABJ404_TYPE_404_DISPLAYED) { $this->addTraceStep('Result', 'Showed 404 page', $redirectUrl); $this->logRedirectHit($redirectUrl, '404', $matchReason, null, $this->trace); $this->triggerAsyncSuggestionsIfNeeded($requestedURL); $this->emitBenchmarkHeadersIfEnabled(); $this->logic->sendTo404Page($requestedURL, $matchReason); return true; } $isRedirectToCustom404Page = false; if ($redirect['type'] == $this->wpTypePost()) { $options = $this->logic->getOptions(); $dest404pageRaw = isset($options['dest404page']) ? $options['dest404page'] : null; $dest404page = is_string($dest404pageRaw) ? $dest404pageRaw : null; if ($dest404page !== null && $this->logic->thereIsAUserSpecified404Page($dest404page)) { $dest404Parts = explode('|', $dest404page); $custom404Id = isset($dest404Parts[0]) ? (int)$dest404Parts[0] : 0; if ($custom404Id > 0 && $redirect['final_dest'] == $custom404Id) { $isRedirectToCustom404Page = true; } } if (!$isRedirectToCustom404Page) { $destPage = $this->callWpFunction('get_post', array($redirect['final_dest']), null); $hasShortcode = (is_object($destPage) && isset($destPage->post_content) && is_string($destPage->post_content)) ? $this->callWpFunction('has_shortcode', array($destPage->post_content, ABJ404_SHORTCODE_NAME), false) : false; if ($hasShortcode) { $isRedirectToCustom404Page = true; } } } if ($isRedirectToCustom404Page) { $this->logic->setCookieWithPreviousRequest(); setcookie(ABJ404_PP . '_STATUS_404', 'true', time() + 20, "/"); $urlSlugOnly = $this->logic->removeHomeDirectory($requestedURL); $spellChecker = abj_service('spell_checker'); $options = $this->logic->getOptions(); // Boundary normalizer: option shape-probing for the suggest_* slice // lives in the VO. See ABJ_404_Solution_SuggestionDisplayOptions. $suggestOpts = ABJ_404_Solution_SuggestionDisplayOptions::fromOptionsArray($options); $spellChecker->findMatchingPosts( $urlSlugOnly, $suggestOpts->getSuggestCatsString(), $suggestOpts->getSuggestTagsString() ); $spellChecker->triggerAsyncSuggestionComputation($requestedURL); } if ($redirect['type'] == ABJ404_TYPE_EXTERNAL) { $this->addTraceStep('Result', 'Redirected to external URL', $redirectFinalDest); $this->logRedirectHit($redirectUrl, $redirectFinalDest, 'external', null, $this->trace); $this->logic->forceRedirect($redirectFinalDest, $redirectCode); exit; } // Guard against broken redirects with missing/invalid destinations. $finalDestRaw = trim($redirectFinalDest); $redirectTypeInt = is_scalar($redirect['type']) ? (int)$redirect['type'] : 0; if ($finalDestRaw === '' && $redirectTypeInt !== ABJ404_TYPE_HOME && $redirectTypeInt !== ABJ404_TYPE_404_DISPLAYED) { $this->logger->warn("Redirect destination missing. Sending request to 404 page instead. Redirect ID: " . $redirectId); $this->addTraceStep('Result', 'Showed 404 page — redirect destination missing', 'rule #' . $redirectId); $this->logRedirectHit($redirectUrl, '404', $matchReason . ' (missing destination)', null, $this->trace); $this->triggerAsyncSuggestionsIfNeeded($requestedURL); $this->emitBenchmarkHeadersIfEnabled(); $this->logic->sendTo404Page($requestedURL, 'missing redirect destination'); return true; } $key = $redirectFinalDest . "|" . (is_scalar($redirect['type']) ? (string)$redirect['type'] : ''); $permalink = ABJ_404_Solution_Functions::permalinkInfoToArray($key, 0); $finalLink = (is_array($permalink) && array_key_exists('link', $permalink)) ? $permalink['link'] : ''; if (!is_string($finalLink) || trim($finalLink) === '' || $finalLink === 'dunno') { $this->logger->warn("Resolved permalink is empty/invalid. Sending request to 404 page instead. Redirect ID: " . $redirectId); $this->addTraceStep('Result', 'Showed 404 page — redirect destination invalid', 'rule #' . $redirectId); $this->logRedirectHit($redirectUrl, '404', $matchReason . ' (invalid destination)', null, $this->trace); $this->triggerAsyncSuggestionsIfNeeded($requestedURL); $this->emitBenchmarkHeadersIfEnabled(); $this->logic->sendTo404Page($requestedURL, 'invalid redirect destination'); return true; } $redirectedTo = esc_url($finalLink); $urlParts = parse_url($redirectedTo); if (is_array($urlParts) && array_key_exists('path', $urlParts)) { $redirectedTo = $urlParts['path']; } $this->addTraceStep('Result', 'Redirected (' . $redirectCode . ')', $redirectedTo); $this->logRedirectHit($redirectUrl, $redirectedTo, $matchReason, null, $this->trace); $sendTo404Page = $this->logic->forceRedirect( $finalLink, $redirectCode, -1, $requestedURL, $isRedirectToCustom404Page ); if ($sendTo404Page) { return true; } exit; } /** * Self-heal a stale DB_VERSION on the frontend so end users get redirects * without needing an admin visit. * * Returns the (possibly fresh) options array. Caller must re-check * DB_VERSION before continuing — this method may return without healing * (cooldown active, lock held by another worker, or upgrade failed). * * Throttled by a transient so concurrent 404s don't all queue on the * synchronizer lock. updateToNewVersion() is itself locked * (synchronizerAcquireLockTry), so the worst case is a single 300ms * lock-acquire attempt per cooldown window. * * @param array $options Current options as returned by getOptions(true). * @return array Options after attempted recovery. */ private function recoverDbVersionIfStale(array $options): array { $cooldownKey = 'abj404_frontend_db_recovery_cooldown'; if (function_exists('get_transient') && get_transient($cooldownKey)) { return $options; } // Set the cooldown BEFORE attempting recovery so concurrent requests // bail immediately rather than piling onto the lock. if (function_exists('set_transient')) { set_transient($cooldownKey, '1', 5 * 60); } try { $upgraded = $this->logic->updateToNewVersion($options); if (is_array($upgraded)) { $options = $upgraded; } } catch (\Throwable $e) { $this->logger->warn('Frontend DB version recovery failed: ' . $e->getMessage()); return $options; } // updateToNewVersion ends in updateOptions() which clears the resolved- // options cache, so getOptions(true) returns fresh values from the DB. $fresh = $this->logic->getOptions(true); if (is_array($fresh) && isset($fresh['DB_VERSION']) && $fresh['DB_VERSION'] == ABJ404_VERSION) { return $fresh; } $observed = (isset($fresh['DB_VERSION']) && is_scalar($fresh['DB_VERSION'])) ? (string)$fresh['DB_VERSION'] : '(missing)'; $this->logger->warn(sprintf( 'Frontend DB_VERSION still stale after recovery attempt: have=%s expected=%s', $observed, ABJ404_VERSION )); return $fresh; } /** * Trigger async suggestion computation only when needed. * @param string $requestedURL * @return void */ private function triggerAsyncSuggestionsIfNeeded($requestedURL) { if ($this->spellChecker->does404PageHaveSuggestionsShortcode()) { $this->spellChecker->triggerAsyncSuggestionComputation($requestedURL); } } }