1<?php 2 3if (!defined('DOKU_INC')) die(); 4if (!defined('DOKU_PLUGIN')) define('DOKU_PLUGIN', DOKU_INC . 'lib/plugins/'); 5// Needed for the page lookup 6require_once(DOKU_INC . 'inc/fulltext.php'); 7// Needed to get the redirection manager 8require_once(DOKU_PLUGIN . 'action.php'); 9 10class action_plugin_404manager extends DokuWiki_Action_Plugin 11{ 12 13 14 var $targetId = ''; 15 var $sourceId = ''; 16 17 // The redirect source 18 const REDIRECT_TARGET_PAGE_FROM_DATASTORE = 'dataStore'; 19 const REDIRECT_EXTERNAL = 'External'; 20 const REDIRECT_SOURCE_START_PAGE = 'startPage'; 21 const REDIRECT_SOURCE_BEST_PAGE_NAME = 'bestPageName'; 22 const REDIRECT_SOURCE_BEST_NAMESPACE = 'bestNamespace'; 23 const REDIRECT_SEARCH_ENGINE = 'searchEngine'; 24 25 // The constant parameters 26 const GO_TO_SEARCH_ENGINE = 'GoToSearchEngine'; 27 const GO_TO_BEST_NAMESPACE = 'GoToBestNamespace'; 28 const GO_TO_BEST_PAGE_NAME = 'GoToBestPageName'; 29 const GO_TO_NS_START_PAGE = 'GoToNsStartPage'; 30 const GO_TO_EDIT_MODE = 'GoToEditMode'; 31 const NOTHING = 'Nothing'; 32 33 /** 34 * The object that holds all management function 35 * @var admin_plugin_404manager 36 */ 37 var $redirectManager; 38 39 /* 40 * The event scope is made object 41 */ 42 var $event; 43 44 // The action name is used as check / communication channel between function hooks. 45 // It will comes in the global $ACT variable 46 const ACTION_NAME = '404manager'; 47 48 // The name in the session variable 49 const MANAGER404_MSG = '404manager_msg'; 50 51 // To identify the object 52 private $objectId; 53 54 // Query String variable name to send the redirection message 55 const QUERY_STRING_ORIGIN_PAGE = '404id'; 56 const QUERY_STRING_REDIR_TYPE = '404type'; 57 58 // Message 59 private $message; 60 61 62 function __construct() 63 { 64 // enable direct access to language strings 65 $this->setupLocale(); 66 require_once(dirname(__FILE__) . '/Message404.php'); 67 $this->message = new Message404(); 68 } 69 70 71 function register(Doku_Event_Handler $controller) 72 { 73 74 $this->objectId = spl_object_hash($this); 75 76 /* This will call the function _handle404 */ 77 $controller->register_hook('DOKUWIKI_STARTED', 78 'AFTER', 79 $this, 80 '_handle404', 81 array()); 82 83 /* This will call the function _displayRedirectMessage */ 84 $controller->register_hook( 85 'TPL_ACT_RENDER', 86 'BEFORE', 87 $this, 88 '_displayRedirectMessage', 89 array() 90 ); 91 92 } 93 94 /** 95 * Verify if there is a 404 96 * Inspiration comes from <a href="https://github.com/splitbrain/dokuwiki-plugin-notfound/blob/master/action.php">Not Found Plugin</a> 97 * @param $event Doku_Event 98 * @param $param 99 * @return bool not required 100 * @throws Exception 101 */ 102 function _handle404(&$event, $param) 103 { 104 105 global $ACT; 106 if ($ACT != 'show') return false; 107 108 global $INFO; 109 if ($INFO['exists']) return false; 110 111 // We instantiate the redirect manager because it's use overall 112 // it holds the function and methods 113 require_once(dirname(__FILE__) . '/admin.php'); 114 if ($this->redirectManager == null) { 115 $this->redirectManager = admin_plugin_404manager::get(); 116 } 117 // Event is also used in some sub-function, we make it them object scope 118 $this->event = $event; 119 120 121 // Global variable needed in the process 122 global $ID; 123 global $conf; 124 $targetPage = $this->redirectManager->getRedirectionTarget($ID); 125 126 // If this is an external redirect 127 if ($this->redirectManager->isValidURL($targetPage) && $targetPage) { 128 129 $this->redirectToExternalPage($targetPage); 130 return true; 131 132 } 133 134 // Internal redirect 135 136 // Their is one action for a writer: 137 // * edit mode direct 138 // If the user is a writer (It have the right to edit). 139 If ($this->userCanWrite() && $this->getConf(self::GO_TO_EDIT_MODE) == 1) { 140 141 $this->gotToEditMode($event); 142 // Stop here 143 return true; 144 145 } 146 147 // This is a reader 148 // Their are only three actions for a reader: 149 // * redirect to a page (show another page id) 150 // * go to the search page 151 // * do nothing 152 153 // If the page exist 154 if (page_exists($targetPage)) { 155 156 $this->redirectToDokuwikiPage($targetPage, self::REDIRECT_TARGET_PAGE_FROM_DATASTORE); 157 return true; 158 159 } 160 161 // We are still a reader, the redirection does not exist the user not allowed to edit the page (public of other) 162 if ($this->getConf('ActionReaderFirst') == self::NOTHING) { 163 return true; 164 } 165 166 // We are reader and their is no redirection set, we apply the algorithm 167 $readerAlgorithms = array(); 168 $readerAlgorithms[0] = $this->getConf('ActionReaderFirst'); 169 $readerAlgorithms[1] = $this->getConf('ActionReaderSecond'); 170 $readerAlgorithms[2] = $this->getConf('ActionReaderThird'); 171 172 $i = 0; 173 while (isset($readerAlgorithms[$i])) { 174 175 switch ($readerAlgorithms[$i]) { 176 177 case self::NOTHING: 178 return true; 179 break; 180 181 case self::GO_TO_NS_START_PAGE: 182 183 // Start page with the conf['start'] parameter 184 $startPage = getNS($ID) . ':' . $conf['start']; 185 if (page_exists($startPage)) { 186 $this->redirectToDokuwikiPage($startPage, self::REDIRECT_SOURCE_START_PAGE); 187 return true; 188 } 189 // Start page with the same name than the namespace 190 $startPage = getNS($ID) . ':' . curNS($ID); 191 if (page_exists($startPage)) { 192 $this->redirectToDokuwikiPage($startPage, self::REDIRECT_SOURCE_START_PAGE); 193 return true; 194 } 195 break; 196 197 case self::GO_TO_BEST_PAGE_NAME: 198 199 $bestPageId = null; 200 201 202 $bestPage = $this->getBestPage($ID); 203 $bestPageId = $bestPage['id']; 204 $scorePageName = $bestPage['score']; 205 206 // Get Score from a Namespace 207 $bestNamespace = $this->scoreBestNamespace($ID); 208 $bestNamespaceId = $bestNamespace['namespace']; 209 $namespaceScore = $bestNamespace['score']; 210 211 // Compare the two score 212 if ($scorePageName > 0 or $namespaceScore > 0) { 213 if ($scorePageName > $namespaceScore) { 214 $this->redirectToDokuwikiPage($bestPageId, self::REDIRECT_SOURCE_BEST_PAGE_NAME); 215 } else { 216 $this->redirectToDokuwikiPage($bestNamespaceId, self::REDIRECT_SOURCE_BEST_PAGE_NAME); 217 } 218 return true; 219 } 220 break; 221 222 case self::GO_TO_BEST_NAMESPACE: 223 224 $scoreNamespace = $this->scoreBestNamespace($ID); 225 $bestNamespaceId = $scoreNamespace['namespace']; 226 $score = $scoreNamespace['score']; 227 228 if ($score > 0) { 229 $this->redirectToDokuwikiPage($bestNamespaceId, self::REDIRECT_SOURCE_BEST_NAMESPACE); 230 return true; 231 } 232 break; 233 234 case self::GO_TO_SEARCH_ENGINE: 235 236 $this->redirectToSearchEngine(); 237 238 return true; 239 break; 240 241 // End Switch Action 242 } 243 244 $i++; 245 // End While Action 246 } 247 // End if not connected 248 249 return true; 250 251 } 252 253 254 /** 255 * Main function; dispatches the visual comment actions 256 * @param $event Doku_Event 257 */ 258 function _displayRedirectMessage(&$event, $param) 259 { 260 261 // After a redirect to another page via query string ? 262 global $INPUT; 263 // Comes from method redirectToDokuwikiPage 264 $pageIdOrigin = $INPUT->str(self::QUERY_STRING_ORIGIN_PAGE); 265 266 if ($pageIdOrigin) { 267 268 $redirectSource = $INPUT->str(self::QUERY_STRING_REDIR_TYPE); 269 270 switch ($redirectSource) { 271 272 case self::REDIRECT_TARGET_PAGE_FROM_DATASTORE: 273 $this->message->addContent(sprintf($this->lang['message_redirected_by_redirect'], hsc($pageIdOrigin))); 274 $this->message->setType(Message404::TYPE_CLASSIC); 275 break; 276 277 case self::REDIRECT_SOURCE_START_PAGE: 278 $this->message->addContent(sprintf($this->lang['message_redirected_to_startpage'], hsc($pageIdOrigin))); 279 $this->message->setType(Message404::TYPE_WARNING); 280 break; 281 282 case self::REDIRECT_SOURCE_BEST_PAGE_NAME: 283 $this->message->addContent(sprintf($this->lang['message_redirected_to_bestpagename'], hsc($pageIdOrigin))); 284 $this->message->setType(Message404::TYPE_WARNING); 285 break; 286 287 case self::REDIRECT_SOURCE_BEST_NAMESPACE: 288 $this->message->addContent(sprintf($this->lang['message_redirected_to_bestnamespace'], hsc($pageIdOrigin))); 289 $this->message->setType(Message404::TYPE_WARNING); 290 break; 291 292 case self::REDIRECT_SEARCH_ENGINE: 293 $this->message->addContent(sprintf($this->lang['message_redirected_to_searchengine'], hsc($pageIdOrigin))); 294 $this->message->setType(Message404::TYPE_WARNING); 295 break; 296 297 } 298 299 // Add a list of page with the same name to the message 300 // if the redirections is not planned 301 if ($redirectSource!=self::REDIRECT_TARGET_PAGE_FROM_DATASTORE) { 302 $this->addToMessagePagesWithSameName($pageIdOrigin); 303 } 304 305 } 306 307 if ($event->data == 'show' || $event->data == 'edit' || $event->data == 'search') { 308 309 $this->printMessage($this->message); 310 311 } 312 } 313 314 315 /** 316 * getBestNamespace 317 * Return a list with 'BestNamespaceId Score' 318 * @param $id 319 * @return array 320 */ 321 private function scoreBestNamespace($id) 322 { 323 324 global $conf; 325 326 // Parameters 327 $pageNameSpace = getNS($id); 328 329 // If the page has an existing namespace start page take it, other search other namespace 330 $startPageNameSpace = $pageNameSpace . ":"; 331 $dateAt = ''; 332 // $startPageNameSpace will get a full path (ie with start or the namespace 333 resolve_pageid($pageNameSpace, $startPageNameSpace, $exists, $dateAt, true); 334 if (page_exists($startPageNameSpace)) { 335 $nameSpaces = array($startPageNameSpace); 336 } else { 337 $nameSpaces = ft_pageLookup($conf['start']); 338 } 339 340 // Parameters and search the best namespace 341 $pathNames = explode(':', $pageNameSpace); 342 $bestNbWordFound = 0; 343 $bestNamespaceId = ''; 344 foreach ($nameSpaces as $nameSpace) { 345 346 $nbWordFound = 0; 347 foreach ($pathNames as $pathName) { 348 if (strlen($pathName) > 2) { 349 $nbWordFound = $nbWordFound + substr_count($nameSpace, $pathName); 350 } 351 } 352 if ($nbWordFound > $bestNbWordFound) { 353 // Take only the smallest namespace 354 if (strlen($nameSpace) < strlen($bestNamespaceId) or $nbWordFound > $bestNbWordFound) { 355 $bestNbWordFound = $nbWordFound; 356 $bestNamespaceId = $nameSpace; 357 } 358 } 359 } 360 361 $startPageFactor = $this->getConf('WeightFactorForStartPage'); 362 $nameSpaceFactor = $this->getConf('WeightFactorForSameNamespace'); 363 if ($bestNbWordFound > 0) { 364 $bestNamespaceScore = $bestNbWordFound * $nameSpaceFactor + $startPageFactor; 365 } else { 366 $bestNamespaceScore = 0; 367 } 368 369 370 return array( 371 'namespace' => $bestNamespaceId, 372 'score' => $bestNamespaceScore 373 ); 374 375 } 376 377 /** 378 * @param $event 379 */ 380 private function gotToEditMode(&$event) 381 { 382 global $ID; 383 global $conf; 384 385 386 global $ACT; 387 $ACT = 'edit'; 388 389 // If this is a side bar no message. 390 // There is always other page with the same name 391 $pageName = noNS($ID); 392 if ($pageName != $conf['sidebar']) { 393 394 if ($this->getConf('ShowMessageClassic') == 1) { 395 $this->message->addContent($this->lang['message_redirected_to_edit_mode']); 396 $this->message->setType(Message404::TYPE_CLASSIC); 397 } 398 399 // If Param show page name unique and it's not a start page 400 $this->addToMessagePagesWithSameName($ID); 401 402 403 } 404 405 406 } 407 408 /** 409 * Return if the user has the right/permission to create/write an article 410 * @return bool 411 */ 412 private function userCanWrite() 413 { 414 global $ID; 415 416 if ($_SERVER['REMOTE_USER']) { 417 $perm = auth_quickaclcheck($ID); 418 } else { 419 $perm = auth_aclcheck($ID, '', null); 420 } 421 422 if ($perm >= AUTH_EDIT) { 423 return true; 424 } else { 425 return false; 426 } 427 } 428 429 /** 430 * Redirect to an internal page, no external resources 431 * @param $targetPage the target page id or an URL 432 * @param string|the $redirectSource the source of the redirect 433 * @throws Exception 434 */ 435 private function redirectToDokuwikiPage($targetPage, $redirectSource = 'Not Known') 436 { 437 438 global $ID; 439 440 //If the user have right to see the target page 441 if ($_SERVER['REMOTE_USER']) { 442 $perm = auth_quickaclcheck($targetPage); 443 } else { 444 $perm = auth_aclcheck($targetPage, '', null); 445 } 446 if ($perm <= AUTH_NONE) { 447 return; 448 } 449 450 // TODO: Create a cache table ? with the source, target and type of redirections ? 451 // if (!$this->redirectManager->isRedirectionPresent($ID)) { 452 // $this->redirectManager->addRedirection($ID, $targetPage); 453 //} 454 455 // Redirection 456 $this->redirectManager->logRedirection($ID, $targetPage, $redirectSource); 457 458 // Explode the page ID and the anchor (#) 459 $link = explode('#', $targetPage, 2); 460 461 // Query String to pass the message 462 $urlParams = array( 463 self::QUERY_STRING_ORIGIN_PAGE => $ID, 464 self::QUERY_STRING_REDIR_TYPE => $redirectSource 465 ); 466 467 // TODO: Status code 468 // header('HTTP/1.1 301 Moved Permanently') will cache it in the browser !!! 469 470 $wl = wl($link[0], $urlParams, true, '&'); 471 if ($link[1]) { 472 $wl .= '#' . rawurlencode($link[1]); 473 } 474 send_redirect($wl); 475 476 } 477 478 /** 479 * Redirect to an internal page, no external resources 480 * @param string $url target page id or an URL 481 */ 482 private function redirectToExternalPage($url) 483 { 484 485 global $ID; 486 487 // No message can be shown because this is an external URL 488 489 // Update the redirections 490 $this->redirectManager->logRedirection($ID, $url, self::REDIRECT_EXTERNAL); 491 492 // TODO: Status code 493 // header('HTTP/1.1 301 Moved Permanently'); 494 send_redirect($url); 495 496 if (defined('DOKU_UNITTEST')) return; // no exits during unit tests 497 exit(); 498 499 } 500 501 /** 502 * @param $id 503 * @return array 504 */ 505 private function getBestPage($id) 506 { 507 508 // The return parameters 509 $bestPageId = null; 510 $scorePageName = null; 511 512 // Get Score from a page 513 $pageName = noNS($id); 514 $pagesWithSameName = ft_pageLookup($pageName); 515 if (count($pagesWithSameName) > 0) { 516 517 // Search same namespace in the page found than in the Id page asked. 518 $bestNbWordFound = 0; 519 520 521 $wordsInPageSourceId = explode(':', $id); 522 foreach ($pagesWithSameName as $targetPageId => $title) { 523 524 // Nb of word found in the target page id 525 // that are in the source page id 526 $nbWordFound = 0; 527 foreach ($wordsInPageSourceId as $word) { 528 $nbWordFound = $nbWordFound + substr_count($targetPageId, $word); 529 } 530 531 if ($bestPageId == null) { 532 533 $bestNbWordFound = $nbWordFound; 534 $bestPageId = $targetPageId; 535 536 } else { 537 538 if ($nbWordFound >= $bestNbWordFound && strlen($bestPageId) > strlen($targetPageId)) { 539 540 $bestNbWordFound = $nbWordFound; 541 $bestPageId = $targetPageId; 542 543 } 544 545 } 546 547 } 548 $scorePageName = $this->getConf('WeightFactorForSamePageName') + ($bestNbWordFound - 1) * $this->getConf('WeightFactorForSameNamespace'); 549 return array( 550 'id' => $bestPageId, 551 'score' => $scorePageName); 552 } 553 return array( 554 'id' => $bestPageId, 555 'score' => $scorePageName 556 ); 557 558 } 559 560 /** 561 * Add the page with the same page name but in an other location 562 * @param $pageId 563 */ 564 private function addToMessagePagesWithSameName($pageId) 565 { 566 567 global $conf; 568 569 $pageName = noNS($pageId); 570 if ($this->getConf('ShowPageNameIsNotUnique') == 1 && $pageName <> $conf['start']) { 571 572 //Search same page name 573 $pagesWithSameName = ft_pageLookup($pageName); 574 575 if (count($pagesWithSameName) > 0) { 576 577 $this->message->setType(Message404::TYPE_WARNING); 578 579 // Assign the value to a variable to be able to use the construct .= 580 if ($this->message->getContent() <> '') { 581 $this->message->addContent('<br/><br/>'); 582 } 583 $this->message->addContent($this->lang['message_pagename_exist_one']); 584 $this->message->addContent('<ul>'); 585 586 $i = 0; 587 foreach ($pagesWithSameName as $PageId => $title) { 588 $i++; 589 if ($i > 10) { 590 $this->message->addContent('<li>' . 591 tpl_link( 592 "doku.php?id=" . $pageId . "&do=search&q=" . rawurldecode($pageName), 593 "More ...", 594 'class="" rel="nofollow" title="More..."', 595 $return = true 596 ) . '</li>'); 597 break; 598 } 599 if ($title == null) { 600 $title = $PageId; 601 } 602 $this->message->addContent('<li>' . 603 tpl_link( 604 wl($PageId), 605 $title, 606 'class="" rel="nofollow" title="' . $title . '"', 607 $return = true 608 ) . '</li>'); 609 } 610 $this->message->addContent('</ul>'); 611 } 612 } 613 } 614 615 /** 616 * @param $message 617 */ 618 private function printMessage($message): void 619 { 620 if ($this->message->getContent() <> "") { 621 $pluginInfo = $this->getInfo(); 622 // a class can not start with a number then 404manager is not a valid class name 623 $redirectManagerClass = "redirect-manager"; 624 625 if ($this->message->getType() == Message404::TYPE_CLASSIC) { 626 ptln('<div class="alert alert-success ' . $redirectManagerClass . '" role="alert">'); 627 } else { 628 ptln('<div class="alert alert-warning ' . $redirectManagerClass . '" role="alert">'); 629 } 630 631 print $this->message->getContent(); 632 633 634 print '<div class="managerreference">' . $this->lang['message_come_from'] . ' <a href="' . $pluginInfo['url'] . '" class="urlextern" title="' . $pluginInfo['desc'] . '" rel="nofollow">' . $pluginInfo['name'] . '</a>.</div>'; 635 print('</div>'); 636 } 637 } 638 639 /** 640 * Redirect to the search engine 641 */ 642 private function redirectToSearchEngine() 643 { 644 645 global $ID; 646 647 $replacementPart = array(':', '_', '-'); 648 $query = str_replace($replacementPart, ' ', $ID); 649 650 $urlParams = array( 651 "do" => "search", 652 "q" => $query, 653 self::QUERY_STRING_ORIGIN_PAGE => $ID, 654 self::QUERY_STRING_REDIR_TYPE => self::REDIRECT_SEARCH_ENGINE 655 ); 656 657 // TODO: Status code ? 658 // header('HTTP/1.1 301 Moved Permanently') will cache it in the browser !!! 659 660 $url = wl($ID, $urlParams, true, '&'); 661 662 send_redirect($url); 663 664 } 665 666 667} 668