monster_menus-9.0.x-dev/modules/mm_detailed_404/mm_detailed_404.module
modules/mm_detailed_404/mm_detailed_404.module
<?php
use Drupal\Core\Cache\Cache;
use Drupal\Core\Database\Database;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Link;
use Drupal\Core\Url;
use Drupal\monster_menus\Constants;
use Symfony\Component\Routing\Route;
/**
* Provide a more detailed 404 (Not Found) error page, including suggested
* locations where the intended page may be found.
*
* This module's settings can be configured at
* admin/config/system/site-information.
*/
/**
* Implements hook_mm_showpage_routing().
*/
function mm_detailed_404_mm_showpage_routing() {
$items = [];
// This hook will only have an effect if the 404 page is in the MM tree.
if ($mmtid = mm_detailed_404_get_page_404_mmtid()) {
$external_path = substr(mm_content_get_mmtid_url($mmtid, ['base_url' => ''])->toString(), 1);
$items[$external_path] = [
'page callback' => 'mm_detailed_404_showpage',
'page arguments' => [],
];
}
return $items;
}
function mm_detailed_404_get_page_404_mmtid() {
$page_404 = \Drupal::service('config.factory')->getEditable('system.site')
->get('page.404');
// This module will only have an effect if the 404 page is in the MM tree.
if (preg_match('{^/mm/(\d+)(?:/|$)}', $page_404, $matches)) {
return $matches[1];
}
if ($mmtid = \Drupal::service('monster_menus.path_processor_inbound')->getMmtidOfPath($page_404)) {
return $mmtid;
}
}
function mm_detailed_404_showpage() {
// Don't bother generating a list of alternative pages for HEAD and the like.
if (\Drupal::request()->getMethod() == 'GET') {
$bad_path = mm_get_current_path();
if ($page_404_mmtid = mm_detailed_404_get_page_404_mmtid()) {
if ($bad_path == "mm/$page_404_mmtid") {
return t('<p>The detailed list of suggestions would normally appear here.</p>');
}
else {
if ($detailed = mm_detailed_404_detail($bad_path)) {
return ['output_post' => $detailed];
}
}
}
}
}
/**
* When the requested URL is not found, evaluate it and try to come up with
* some possible guesses as to what the user mis-typed, or to where the intended
* page has moved.
*
* @param $path
* Path to evaluate
* @return array|NULL
* Render array containing the possible guesses. If NULL is returned, the
* calling code should generate a default 'page not found' message.
*/
function mm_detailed_404_detail($path) {
$config = \Drupal::service('config.factory')->get('mm_detailed_404.settings');
$db = Database::getConnection();
$max_results = $config->get('max_results');
$fuzzy = $config->get('fuzzy');
$output = [
'preamble' => [
'#type' => 'processed_text',
'#text' => $config->get('preamble.value'),
'#format' => $config->get('preamble.format'),
],
'postamble' => [
'#type' => 'processed_text',
'#text' => $config->get('postamble.value'),
'#format' => $config->get('postamble.format'),
'#weight' => 1000,
],
];
$skipped = FALSE;
$home_mmtid = mm_home_mmtid();
$path = explode('/', $path);
// Save the path to the missing page in a special query arg, which is only
// used internally for cache contexts.
$output['#cache']['contexts'] = ['url.query_args:_mm404'];
\Drupal::request()->query->add(['_mm404' => $path]);
// Tag this so that it can be invalidated when the settings change.
$output['#cache']['tags'] = ['mm_detailed_404'];
if ($path[0] == 'mm') {
$found_mmtid = intval($path[1] ?? 0);
$path = array_slice($path, 2);
}
else if ($path[0] == 'system') {
$path = [];
}
else {
$found_mmtid = $home_mmtid;
}
$mtime = 0;
$output_list = $seen = [];
$prefix_single = t('<p>The page you are looking for appears to have moved to:') . ' ';
foreach ($path as $child) {
$select = $db->select('mm_tree_revision', 'r');
$select->leftJoin('mm_tree', 't', 't.mmtid = r.mmtid');
$num_revs = $select->condition('r.parent', $found_mmtid)
->condition('r.alias', $child)
->isNull('t.mmtid')
->countQuery()->execute()->fetchField();
if ($num_revs) {
$prefix_single = t('<p>The page you requested has been permanently deleted.');
if (!$output_list && $found_mmtid != $home_mmtid) {
$output_list[0] = $found_mmtid;
}
if ($output_list) {
$prefix_single .= ' ' . t('You may be able to find what you were looking for here:') . ' ';
}
else {
$output['items']['#markup'] = $prefix_single;
return $output;
}
}
}
while ($child = array_shift($path)) {
$params = [':parent' => $found_mmtid, ':alias' => $child];
$use_mtime = '';
if ($mtime > 0) {
$use_mtime = ' AND r.mtime <= :mtime';
$params[':mtime'] = $mtime;
}
$mtime_query = $db->query("SELECT MAX(r.mmtid) AS mmtid, MAX(r.mtime) AS mtime FROM {mm_tree_revision} r INNER JOIN {mm_tree} t ON t.mmtid = r.mmtid WHERE r.parent = :parent AND r.alias = :alias AND LEFT(r.name, 1) <> '.'$use_mtime GROUP BY r.mmtid ORDER BY MAX(r.vid) DESC", $params);
if ($row = $mtime_query->fetchObject()) {
if (mm_content_user_can($row->mmtid, Constants::MM_PERMS_READ)) {
$output_list[0] = $found_mmtid = $row->mmtid;
$mtime = $row->mtime;
continue;
}
else {
$skipped = TRUE;
}
}
$output_list = $exclude = [];
// Use the SQL SOUNDEX() function instead of the PHP soundex() version,
// because they produce different values
$soundex = $fuzzy ? _mm_detailed_404_soundex($child) : '';
$soundex_short = substr($soundex, 1, 3);
$queries = [
// Menu router paths
'hook' => ["SELECT * FROM {router} WHERE SUBSTR(path, 1, 13) <> '/mm/{mm_tree}' AND (SUBSTRING(SUBSTRING_INDEX(path, '/', 2), 2) = :child OR SOUNDEX(SUBSTRING(SUBSTRING_INDEX(path, '/', 2), 2)) = :soundex) ORDER BY fit DESC", [':child' => $child, ':soundex' => $soundex]],
// Direct alias match at the correct level of the tree
'p+a+' => ['r.parent = :found_mmtid AND r.alias = :child', [':found_mmtid' => $found_mmtid, ':child' => $child]],
// Alias starts with the string, at the correct level
'p+a*' => ['r.parent = :found_mmtid AND r.alias LIKE :child', [':found_mmtid' => $found_mmtid, ':child' => $child . '%']],
// Alias sounds like the string, at the correct level
'p+as' => ['r.parent = :found_mmtid AND SOUNDEX(r.alias) = :soundex', [':found_mmtid' => $found_mmtid, ':soundex' => $soundex]],
// Alias matches at any level
'p-a+' => ['r.alias = :child', [':child' => $child]],
// Alias sounds like the string, at any level
'p-as' => ['r.parent <> :found_mmtid AND SOUNDEX(r.alias) = :soundex', [':found_mmtid' => $found_mmtid, ':soundex' => $soundex]],
// Alias sounds like the string, at any level, using minimal match
'p<as' => ['r.parent <> :found_mmtid AND SUBSTR(SOUNDEX(r.alias), 2, 3) = :soundex_short', [':found_mmtid' => $found_mmtid, ':soundex_short' => $soundex_short]],
];
if (!$fuzzy) {
$queries['hook'] = ["SELECT * FROM {router} WHERE SUBSTR(path, 1, 13) <> '/mm/{mm_tree}' AND SUBSTRING(SUBSTRING_INDEX(path, '/', 2), 2) = :child ORDER BY fit DESC", [':child' => $child]];
unset($queries['p+as']);
unset($queries['p-as']);
unset($queries['p<as']);
}
$soundex_bad = FALSE;
foreach ($queries as $index => $params) {
if ($index == 'p+as') {
// See if there is enough variance in the soundex value, by checking
// the number of consonants and the frequency of resulting digits.
$len_test = preg_replace('/[^bcdfghjklmnpqrstvwxyz]/i', '', $child);
if (strlen($len_test) < 3 || strlen(count_chars($soundex, 3)) / strlen($soundex) <= 2/3) {
$soundex_bad = TRUE;
continue;
}
}
elseif ($index == 'p-a+') {
if (count($output_list) == 1) {
$found_mmtid = $output_list[0];
break;
}
}
elseif ($index == 'p-as' || $index == 'p<as') {
// This is the last-ditch effort, only if everything before has
// failed, and the soundex value is sufficiently unique
if (count($output_list) || $soundex_bad) {
break;
}
// Reduce max. to no more than 5.
$max_results = min(5, $max_results);
}
elseif ($index == 'hook') {
// Search in the menu router list for a close match, using just the
// first element of the path. Only do this when MM found no match at
// all.
if ($found_mmtid != $home_mmtid) {
continue;
}
$path_validator = \Drupal::pathValidator();
$test_path = $path;
array_unshift($test_path, $child);
$results = $db->query($params[0], $params[1]);
foreach ($results as $menu) {
$matched = TRUE;
$link_params = [];
foreach (explode('/', ltrim($menu->pattern_outline, '/')) as $path_index => $elem) {
if ($path_index >= count($test_path)) {
$matched = FALSE;
break;
}
if ($elem == '%') {
/** @var Route $route */
$route = unserialize($menu->route);
$compiled = $route->compile();
$variables = $compiled->getVariables();
$link_params[$variables[count($link_params)]] = $test_path[$path_index];
}
elseif (strcasecmp($test_path[$path_index], $elem) && (!$fuzzy || _mm_detailed_404_soundex($test_path[$path_index]) != _mm_detailed_404_soundex($elem))) {
$matched = FALSE;
break;
}
}
if ($matched) {
try {
$test_url = Url::fromRoute($menu->name, $link_params, ['base_url' => ''])->toString();
// Only include URLs that aren't where we already are and the
// current user has access to.
if ($test_url != '/' . implode('/', $test_path) && $path_validator->isValid($test_url)) {
$link_text = Url::fromRoute($menu->name, $link_params)->toString();
$output_list[] = Link::createFromRoute($link_text, $menu->name, $link_params, ['attributes' => ['rel' => 'nofollow']]);
}
}
catch (\Exception) {
// Not a valid URL, probably due to bad parameters, so exclude it.
}
}
}
if (count($output_list)) {
// Stop looking.
$path = [];
}
continue; // Go to next query.
}
$where = $params[0];
$params = $params[1];
// There's no need to do matches against mm_tree, since its data is
// duplicated in mm_tree_revision in the most recent revision.
// Don't match items or parents with a name starting with '.'.
$query = "SELECT MAX(r.mmtid) AS mmtid FROM {mm_tree_revision} r INNER JOIN {mm_tree} t ON t.mmtid = r.mmtid WHERE $where AND LEFT(r.name, 1) <> '.' AND (SELECT COUNT(*) FROM {mm_tree} t2 INNER JOIN {mm_tree_parents} p ON p.parent = t2.mmtid WHERE p.mmtid = r.mmtid AND LEFT(t2.name, 1) = '.') = 0";
// Skip mmtids already found
if ($exclude) {
$query .= ' AND r.mmtid NOT IN (:exclude[])';
$params[':exclude[]'] = $exclude;
}
$results = $db->query($query . ' GROUP BY r.mmtid ORDER BY MAX(r.vid) DESC', $params);
while (count($output_list) < $max_results && ($item = $results->fetchObject())) {
if (!mm_content_user_can($item->mmtid, Constants::MM_PERMS_READ)) {
$skipped = TRUE;
}
else if (!isset($seen[$item->mmtid])) {
$output_list[] = $exclude[] = $item->mmtid;
$seen[$item->mmtid] = 1;
}
}
if (count($output_list) >= $max_results) {
break;
}
}
if (!$output_list) {
if ($found_mmtid == $home_mmtid) {
return $output;
}
$output_list[0] = $found_mmtid;
$prefix_single = t('<p>The page you are looking for was not found, but you might be able to find it here:') . ' ';
break;
}
$prefix_single = t('<p>This page might be what you are looking for:<br />');
if (count($output_list) != 1) {
break;
}
}
if (count($output_list) == 1 && is_numeric($output_list[0]) && mm_content_is_recycled($output_list[0])) {
// We'll only get here if the page is readable by the user
$output['items']['#markup'] = t('<p>The page you requested has been marked for future deletion. Therefore, it is no longer accessible.</p>');
return $output;
}
foreach ($output_list as $index => $elem) {
if (!is_numeric($elem)) {
$output_list[$index] = $elem;
}
else {
try {
$link_text = mm_content_get_mmtid_url($elem)->toString();
$link_path = mm_content_get_mmtid_url($elem, ['absolute' => TRUE, 'attributes' => ['rel' => 'nofollow']]);
$output_list[$index] = Link::fromTextAndUrl($link_text, $link_path);
}
catch (\Exception) {
unset($output_list[$index]);
continue;
}
}
}
$post = \Drupal::currentUser()->isAnonymous() && $skipped ? t('<p>You might get more results if you log-in.</p>') : '';
if (count($output_list) == 1) {
$output['items'] = [
['#markup' => $prefix_single],
$output_list[0]->toRenderable(),
['#markup' => '</p>' . $post],
];
return $output;
}
if ($output_list) {
$output['items'] = [
'#theme' => 'item_list',
'#title' => t('<p>One of these pages might be what you are looking for:'),
'#items' => $output_list,
'#suffix' => $post,
];
return $output;
}
if (\Drupal::currentUser()->isAnonymous() && $skipped) {
$output['items']['#markup'] = t('This page may be able to provide you with suggestions as to where to find what you are looking for, but you need to log-in first.');
}
return $output;
}
function _mm_detailed_404_soundex($string) {
static $cache = [];
// The PHP soundex() function is incorrect, so use the SQL version, but cache
// the results.
$string = preg_replace('/[\x80-\xFF]/', '', $string);
if (!isset($cache[$string])) {
$cache[$string] = Database::getConnection()->query('SELECT SOUNDEX(:string)', [':string' => $string])->fetchField();
}
return $cache[$string];
}
function mm_detailed_404_form_system_site_information_settings_alter(&$form) {
$config = \Drupal::service('config.factory')->get('mm_detailed_404.settings');
$form['error_page']['detailed_404'] = [
'#type' => 'details',
'#title' => t('Detailed 404 (not found) settings'),
'#open' => TRUE,
'#states' => [
// Only show details when there is something in the site_404 field.
'visible' => ['#edit-site-404' => ['filled' => TRUE]],
],
'preamble' => [
'#type' => 'text_format',
'#title' => t('Preamble'),
'#default_value' => $config->get('preamble.value'),
'#format' => $config->get('preamble.format'),
'#description' => t('The preamble is displayed under any other nodes on the 404 page, followed by the suggested links.'),
],
'max_results' => [
'#type' => 'number',
'#title' => t('Maximum number of suggested links'),
'#min' => 1,
'#default_value' => $config->get('max_results'),
],
'fuzzy' => [
'#type' => 'checkbox',
'#title' => t('Use fuzzy matching'),
'#description' => t('If set, show a list of pages having aliases that are fuzzy (approximate) matches for the URL provided. This can be slow if you have lots of pages.'),
'#default_value' => $config->get('fuzzy'),
],
'postamble' => [
'#type' => 'text_format',
'#title' => t('Postamble'),
'#default_value' => $config->get('postamble.value'),
'#format' => $config->get('postamble.format'),
'#description' => t('The postamble appears at the bottom of the 404 page.'),
],
];
$form['#submit'][] = '_mm_detailed_404_form_system_site_information_settings_submit';
}
function _mm_detailed_404_form_system_site_information_settings_submit(array &$form, FormStateInterface $form_state) {
\Drupal::service('config.factory')->getEditable('mm_detailed_404.settings')
->set('preamble', $form_state->getValue('preamble'))
->set('max_results', $form_state->getValue('max_results'))
->set('fuzzy', $form_state->getValue('fuzzy'))
->set('postamble', $form_state->getValue('postamble'))
->save();
// Invalidate any cached 404 results.
Cache::invalidateTags(['mm_detailed_404']);
}
