f = $f; $this->logger = $logger; $this->contentRepo = $contentRepo; $this->statsRepo = $statsRepo; $this->urlNormalization = $urlNormalization; $this->notFoundResponse = $notFoundResponse; } /** * Build the final redirect destination URL. * * @param string $location Base redirect destination. * @param string $requestedURL Original requested URL (used for custom 404 ref tracking). * @param bool $isCustom404 Whether we are redirecting to a custom 404 page. * @return string Redirect destination suitable for wp_redirect(). */ public function buildFinalRedirectDestination($location, $requestedURL = '', $isCustom404 = false) { $location = $this->urlNormalization->maybeTranslateRedirectUrl($location, $requestedURL); $commentPartAndQueryPart = $this->notFoundResponse->getCommentPartAndQueryPartOfRequest(); $finalDestination = (string)$location . $commentPartAndQueryPart; if ($isCustom404 && is_string($requestedURL) && $requestedURL !== '') { $refUrlResult = preg_replace('/\?.*/', '', $requestedURL); $refUrl = is_string($refUrlResult) ? $refUrlResult : $requestedURL; $refParam = ABJ404_PP . '_ref'; if (function_exists('remove_query_arg')) { $finalDestination = remove_query_arg($refParam, $finalDestination); } if (function_exists('add_query_arg')) { $finalDestination = add_query_arg($refParam, rawurlencode($refUrl), $finalDestination); } else { $separator = (strpos($finalDestination, '?') === false) ? '?' : '&'; $finalDestination .= $separator . $refParam . '=' . rawurlencode($refUrl); } } $finalDestCleaned = preg_replace("/[\\r\\n]+/", '', (string)$finalDestination); $finalDestination = is_string($finalDestCleaned) ? $finalDestCleaned : (string)$finalDestination; if (function_exists('wp_sanitize_redirect')) { $finalDestination = wp_sanitize_redirect($finalDestination); } elseif (function_exists('esc_url_raw')) { $finalDestination = esc_url_raw($finalDestination); } return (string)$finalDestination; } /** Order pages and set the page depth for child pages. * @param array $pages * @param bool $includeMissingParentPages * @return array */ function orderPageResults(array $pages, bool $includeMissingParentPages = false): array { usort($pages, function (object $a, object $b): int { return $this->sortByTypeThenTitle($a, $b); }); $orderedPages = $this->setDepthAndAddChildren($pages); if ($includeMissingParentPages && (count($orderedPages) != count($pages))) { $iterations = 0; do { $idsOfMissingParentPages = $this->getMissingParentPageIDs($pages); $pageCountBefore = count($pages); $iterations = $iterations + 1; foreach ($idsOfMissingParentPages as $pageID) { $postParent = get_post(is_scalar($pageID) ? (int)$pageID : 0); if ($postParent == null) { continue; } $parentPageSlug = $postParent->post_name; $parentPage = $this->contentRepo->getPublishedPagesAndPostsIDs(array('slug' => $parentPageSlug)); if (count($parentPage) != 0) { $pages[] = $parentPage[0]; } } if ($iterations > 30) { break; } $idsOfMissingParentPages = $this->getMissingParentPageIDs($pages); } while ($pageCountBefore != count($pages)); usort($pages, function (object $a, object $b): int { return $this->sortByTypeThenTitle($a, $b); }); $orderedPages = $this->setDepthAndAddChildren($pages); } if (count($orderedPages) != count($pages)) { $unusedPages = array_udiff($pages, $orderedPages, function (object $a, object $b): int { return $this->compareByID($a, $b); }); $this->logger->debugMessage("There was an issue finding the parent pages for some child pages. " . "These pages' parents may not have a 'published' status. Pages: " . wp_kses_post(json_encode($unusedPages) ?: '')); } return $orderedPages; } /** For custom categories we create a Map where the key is the name * of the taxonomy and the list holds the rows that have the category info. * @param array $categoryRows * @return array> */ function getMapOfCustomCategories(array $categoryRows): array { $customTagsEtc = array(); foreach ($categoryRows as $cat) { $taxonomy = $cat->taxonomy; if ($taxonomy == 'category') { continue; } if (!array_key_exists($taxonomy, $customTagsEtc) || $customTagsEtc[$taxonomy] == null) { $customTagsEtc[$taxonomy] = array($cat); } else { array_push($customTagsEtc[$taxonomy], $cat); } } return $customTagsEtc; } /** Returns a list of parent IDs that can't be found in the passed in pages. * @param array $pages * @return array */ function getMissingParentPageIDs(array $pages): array { $listOfIDs = array(); $missingParentPageIDs = array(); foreach ($pages as $page) { /** @var PageObject $page */ $listOfIDs[] = $page->id; } foreach ($pages as $page) { /** @var PageObject $page */ if ($page->post_parent == 0) { continue; } if (in_array($page->post_parent, $listOfIDs)) { continue; } $missingParentPageIDs[] = $page->post_parent; } $missingParentPageIDs = array_merge( array_unique($missingParentPageIDs, SORT_REGULAR), array()); return $missingParentPageIDs; } /** * @param object $a * @param object $b * @return int */ function compareByID(object $a, object $b): int { /** @var PageObject $a */ /** @var PageObject $b */ if ($a->id < $b->id) { return -1; } if ($b->id < $a->id) { return 1; } return 0; } /** Set the depth of each page and add pages under their parents. * @param array $pages * @return array */ function setDepthAndAddChildren(array $pages): array { $childPages = $this->findChildPages($pages); $mainPages = $this->findAllMainPages($pages); $oldChildPageCount = -1; do { $orderedPages = array(); foreach ($mainPages as $page) { /** @var PageObject $page */ $orderedPages[] = $page; $removeThese = array(); foreach ($childPages as $child) { /** @var PageObject $child */ if ($child->post_parent == $page->id) { $parentDepth = $page->depth; /** @var \stdClass $childMut */ $childMut = $child; $childMut->depth = $parentDepth + 1; $removeThese[] = $child; $orderedPages[] = $child; } } $childPages = $this->removeUsedChildPages($childPages, $removeThese); } $mainPages = $orderedPages; if (count($childPages) == $oldChildPageCount) { break; } $oldChildPageCount = count($childPages); } while (count($childPages) > 0); return $orderedPages; } /** * @param array $pages * @return array */ function findAllMainPages(array $pages): array { $mainPages = array(); foreach ($pages as $page) { /** @var PageObject $page */ if ($page->post_parent == 0) { $mainPages[] = $page; } } return $mainPages; } /** * @param array $childPages * @param array $removeThese * @return array */ function removeUsedChildPages(array $childPages, array $removeThese): array { foreach ($removeThese as $removeThis) { $key = array_search($removeThis, $childPages); if ($key !== false) { unset($childPages[$key]); } } return $childPages; } /** Return pages that have a non-0 parent. * @param array $pages * @return array */ function findChildPages(array $pages): array { $childPages = array(); foreach ($pages as $page) { /** @var PageObject $page */ if ($page->post_parent != 0) { $childPages[] = $page; } } return $childPages; } /** * @param object $a * @param object $b * @return int */ function sortByTypeThenTitle(object $a, object $b): int { /** @var PageObject $a */ /** @var PageObject $b */ $result = strcmp($a->post_type, $b->post_type); if ($result != 0) { return $result; } return strcmp($a->post_title, $b->post_title); } /** Send an email if a notification should be displayed. * @return string */ function emailCaptured404Notification() { $options = abj_service('options_repository')->getOptions(true); $frequency = isset($options['admin_notification_frequency']) && is_string($options['admin_notification_frequency']) ? $options['admin_notification_frequency'] : 'instant'; if ($frequency === 'never') { return 'Email notifications disabled: frequency is never.'; } if ($frequency !== 'instant') { $emailDigest = new ABJ_404_Solution_EmailDigest( abj_service('logs_repository'), ABJ_404_Solution_StatsRepositoryResolver::resolve(__CLASS__), $this->logger ); return $emailDigest->sendDigest(); } $captured404Count = $this->statsRepo->getCapturedCountForNotification(); if (!$this->shouldNotifyAboutCaptured404s($captured404Count)) { return "Not enough 404s found to send an admin notification email (" . $captured404Count . ")."; } $captured404URLSettings = admin_url() . "options-general.php?page=" . ABJ404_PP . '&subpage=abj404_captured'; $generalSettings = admin_url() . "options-general.php?page=" . ABJ404_PP . '&subpage=abj404_options'; $to = is_string($options['admin_notification_email']) ? $options['admin_notification_email'] : ''; $subject = '404 Solution: Captured 404 Notification'; $bodyTemplate = ABJ_404_Solution_FileSystemService::readFileContents( dirname(__DIR__) . '/html/emailCapturedNotificationBody.html', false ); $body = str_replace( array( '{captured_count}', '{captured_url}', '{settings_url}', '{sent_at}', '{php_version}', '{plugin_version}', ), array( esc_html((string)$captured404Count), esc_url($captured404URLSettings), esc_url($generalSettings), esc_html(date('Y/m/d h:i:s T', abj_clock()->now())), esc_html(PHP_VERSION), esc_html(ABJ404_VERSION), ), $bodyTemplate ); $headers = array('Content-Type: text/html; charset=UTF-8'); $adminEmail = get_option('admin_email'); $adminEmailStr = is_string($adminEmail) ? $adminEmail : ''; $headers[] = 'From: ' . $adminEmailStr . '<' . $adminEmailStr . '>'; $this->logger->debugMessage("Sending captured 404 notification email to: " . $to); wp_mail($to, $subject, $body, $headers); $this->logger->debugMessage("Captured 404 notification email sent."); return "Captured 404 notification email sent to: " . trim($to); } /** Return true if a notification should be displayed. * @param number $captured404Count the number of captured 404s * @return boolean */ function shouldNotifyAboutCaptured404s($captured404Count) { $options = abj_service('options_repository')->getOptions(true); if (isset($options['admin_notification']) && $options['admin_notification'] != '0') { if ($captured404Count >= $options['admin_notification']) { return true; } } return false; } /** 0|0 => "(Default 404 Page)" * @param string $idAndType * @param string $externalLinkURL * @return string */ function getPageTitleFromIDAndType($idAndType, $externalLinkURL) { if ($idAndType == '') { return ''; } $meta = explode("|", $idAndType); $id = $meta[0]; $type = isset($meta[1]) ? $meta[1] : ''; $typeInt = ABJ_404_Solution_ExactInteger::readOr($type, 0, -1); if ($idAndType == ABJ404_TYPE_404_DISPLAYED . '|' . ABJ404_TYPE_404_DISPLAYED) { return __('(Default 404 Page)', '404-solution'); } else if ($idAndType == ABJ404_TYPE_HOME . '|' . ABJ404_TYPE_HOME) { return __('(Home Page)', '404-solution'); } else if ($typeInt === ABJ404_TYPE_EXTERNAL) { return $externalLinkURL; } else if ($typeInt === ABJ404_TYPE_HOME) { return __('(Home Page)', '404-solution'); } $idInt = (int)$id; if ($typeInt === ABJ404_TYPE_POST) { return get_the_title($idInt); } else if ($typeInt === ABJ404_TYPE_CAT) { $rows = $this->contentRepo->getPublishedCategories($idInt); if (empty($rows)) { $this->logger->debugMessage('No TERM (category) found with ID: ' . $id); return ''; } $firstRow = $rows[0]; return property_exists($firstRow, 'name') ? (string)$firstRow->name : ''; } else if ($typeInt === ABJ404_TYPE_TAG) { $tag = get_tag($idInt); if (is_object($tag) && property_exists($tag, 'name')) { return (string)$tag->name; } return ''; } // Unrecognized/garbage type on a corrupt redirect row is tolerable // bad data: we just can't title it, so we return ''. Per Defensive // Coding Philosophy #8 this degrades at WARNING level rather than // errorMessage() (which fires a production telemetry report). $this->logger->warn("Couldn't get page title. No matching type found for type: " . esc_html($type)); return ''; } }