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