xref: /plugin/combo/action/router.php (revision d8add9b6d6dddc5a1630f969f72a7f91a2da8f6d)
1<?php
2
3
4use ComboStrap\DokuwikiId;
5use ComboStrap\ExceptionBadArgument;
6use ComboStrap\ExceptionBadSyntax;
7use ComboStrap\ExceptionCompile;
8use ComboStrap\ExceptionNotFound;
9use ComboStrap\ExceptionSqliteNotAvailable;
10use ComboStrap\ExecutionContext;
11use ComboStrap\FileSystems;
12use ComboStrap\HttpResponse;
13use ComboStrap\HttpResponseStatus;
14use ComboStrap\Identity;
15use ComboStrap\LogUtility;
16use ComboStrap\MarkupPath;
17use ComboStrap\Mime;
18use ComboStrap\PageRules;
19use ComboStrap\Router;
20use ComboStrap\RouterRedirection;
21use ComboStrap\RouterRedirectionBuilder;
22use ComboStrap\Site;
23use ComboStrap\SiteConfig;
24use ComboStrap\Sqlite;
25use ComboStrap\Web\Url;
26use ComboStrap\Web\UrlEndpoint;
27use ComboStrap\Web\UrlRewrite;
28
29require_once(__DIR__ . '/../vendor/autoload.php');
30
31/**
32 * Class action_plugin_combo_url
33 *
34 * The actual URL manager
35 *
36 *
37 */
38class action_plugin_combo_router extends DokuWiki_Action_Plugin
39{
40
41    /**
42     * @deprecated
43     */
44    const URL_MANAGER_ENABLE_CONF = "enableUrlManager";
45    const ROUTER_ENABLE_CONF = "enableRouter";
46
47
48    // Where the target id value comes from
49
50
51    // The constant parameters
52
53    /** @var string - a name used in log and other places */
54    const NAME = 'Url Manager';
55    const CANONICAL = 'router';
56    const PAGE_404 = "<html lang=\"en\"><body></body></html>";
57    const REFRESH_HEADER_NAME = "Refresh";
58    const REFRESH_HEADER_PREFIX = self::REFRESH_HEADER_NAME . ': 0;url=';
59    const LOCATION_HEADER_PREFIX = HttpResponse::LOCATION_HEADER_NAME . ": ";
60    public const URL_MANAGER_NAME = "Router";
61
62
63    /**
64     * @var PageRules
65     */
66    private $pageRules;
67
68
69    function __construct()
70    {
71        // enable direct access to language strings
72        // ie $this->lang
73        $this->setupLocale();
74
75    }
76
77    /**
78     * @param string $refreshHeader
79     * @return false|string
80     */
81    public static function getUrlFromRefresh(string $refreshHeader)
82    {
83        return substr($refreshHeader, strlen(action_plugin_combo_router::REFRESH_HEADER_PREFIX));
84    }
85
86    public static function getUrlFromLocation($refreshHeader)
87    {
88        return substr($refreshHeader, strlen(action_plugin_combo_router::LOCATION_HEADER_PREFIX));
89    }
90
91
92    /**
93     * Determine if the request should be banned based on the id
94     *
95     * @param string $id
96     * @return bool
97     *
98     * See also {@link https://perishablepress.com/7g-firewall/#features}
99     * for blocking rules on http request data such as:
100     *   * query_string
101     *   * user_agent,
102     *   * remote host
103     */
104    public static function isShadowBanned(string $id): bool
105    {
106        /**
107         * ie
108         * wp-json:api:flutter_woo:config_file
109         * wp-content:plugins:wpdiscuz:themes:default:style-rtl.css
110         * wp-admin
111         * 2020:wp-includes:wlwmanifest.xml
112         * wp-content:start
113         * wp-admin:css:start
114         * sito:wp-includes:wlwmanifest.xml
115         * site:wp-includes:wlwmanifest.xml
116         * cms:wp-includes:wlwmanifest.xml
117         * test:wp-includes:wlwmanifest.xml
118         * media:wp-includes:wlwmanifest.xml
119         * wp2:wp-includes:wlwmanifest.xml
120         * 2019:wp-includes:wlwmanifest.xml
121         * shop:wp-includes:wlwmanifest.xml
122         * wp1:wp-includes:wlwmanifest.xml
123         * news:wp-includes:wlwmanifest.xml
124         * 2018:wp-includes:wlwmanifest.xml
125         */
126        if (strpos($id, 'wp-') !== false) {
127            return true;
128        }
129
130        /**
131         * db:oracle:long_or_1_utl_inaddr.get_host_address_chr_33_chr_126_chr_33_chr_65_chr_66_chr_67_chr_49_chr_52_chr_53_chr_90_chr_81_chr_54_chr_50_chr_68_chr_87_chr_81_chr_65_chr_70_chr_80_chr_79_chr_73_chr_89_chr_67_chr_70_chr_68_chr_33_chr_126_chr_33
132         * db:oracle:999999.9:union:all:select_null:from_dual
133         * db:oracle:999999.9:union:all:select_null:from_dual_and_0_0
134         */
135        if (preg_match('/_chr_|_0_0/', $id) === 1) {
136            return true;
137        }
138
139
140        /**
141         * ie
142         * git:objects:
143         * git:refs:heads:stable
144         * git:logs:refs:heads:main
145         * git:logs:refs:heads:stable
146         * git:hooks:pre-push.sample
147         * git:hooks:pre-receive.sample
148         */
149        if (strpos($id, "git:") === 0) {
150            return true;
151        }
152
153        return false;
154
155    }
156
157    /**
158     * @param string $id
159     * @return bool
160     * well-known:traffic-advice = https://github.com/buettner/private-prefetch-proxy/blob/main/traffic-advice.md
161     * .well-known/security.txt, id=well-known:security.txt = https://securitytxt.org/
162     * well-known:dnt-policy.txt
163     */
164    public static function isWellKnownFile(string $id): bool
165    {
166        return strpos($id, "well-known") === 0;
167    }
168
169
170    function register(Doku_Event_Handler $controller)
171    {
172
173        if (SiteConfig::getConfValue(self::ROUTER_ENABLE_CONF, 1)) {
174
175            /**
176             * This will call the function {@link action_plugin_combo_router::_router()}
177             * The event is not DOKUWIKI_STARTED because this is not the first one
178             *
179             * https://www.dokuwiki.org/devel:event:init_lang_load
180             */
181            $controller->register_hook('DOKUWIKI_STARTED',
182                'BEFORE',
183                $this,
184                'router',
185                array());
186
187            /**
188             * Bot Ban functionality
189             *
190             * Because we make a redirection to the home page, we need to check
191             * if the home is readable, for that, the AUTH plugin needs to be initialized
192             * That's why we wait
193             * https://www.dokuwiki.org/devel:event:dokuwiki_init_done
194             *
195             * and we can't use
196             * https://www.dokuwiki.org/devel:event:init_lang_load
197             * because there is no auth setup in {@link auth_aclcheck_cb()}
198             * and the the line `if (!$auth instanceof AuthPlugin) return AUTH_NONE;` return none;
199             */
200            $controller->register_hook('DOKUWIKI_INIT_DONE', 'BEFORE', $this, 'ban', array());
201
202        }
203
204
205    }
206
207    /**
208     *
209     * We have created a spacial ban function that is
210     * called before the first function
211     * {@link action_plugin_combo_metalang::load_lang()}
212     * to spare CPU.
213     *
214     * @param $event
215     * @throws Exception
216     */
217    function ban(&$event)
218    {
219
220        $id = Router::getOriginalIdFromRequest();
221        if ($id === null) {
222            return;
223        }
224        $page = MarkupPath::createMarkupFromId($id);
225        if (FileSystems::exists($page)) {
226            return;
227        }
228
229        // Well known
230        if (self::isWellKnownFile($id)) {
231            $redirection = RouterRedirectionBuilder::createFromOrigin(RouterRedirection::TARGET_ORIGIN_WELL_KNOWN)
232                ->setType(RouterRedirection::REDIRECT_NOTFOUND_METHOD)
233                ->build();
234            $this->logRedirection($redirection);
235            ExecutionContext::getActualOrCreateFromEnv()
236                ->response()
237                ->setStatus(HttpResponseStatus::NOT_FOUND)
238                ->end();
239            return;
240        }
241
242        // Shadow banned
243        if (self::isShadowBanned($id)) {
244            $webSiteHomePage = MarkupPath::createMarkupFromId(Site::getIndexPageName());
245            $redirection = RouterRedirectionBuilder::createFromOrigin(RouterRedirection::TARGET_ORIGIN_SHADOW_BANNED)
246                ->setType(RouterRedirection::REDIRECT_TRANSPARENT_METHOD)
247                ->setTargetMarkupPath($webSiteHomePage)
248                ->build();
249            $this->executeTransparentRedirect($redirection);
250        }
251
252    }
253
254    /**
255     * @param $event Doku_Event
256     * @param $param
257     * @return void
258     */
259    function router(Doku_Event &$event, $param)
260    {
261
262        /**
263         * Just the {@link ExecutionContext::SHOW_ACTION}
264         * may be redirected
265         */
266        $executionContext = ExecutionContext::getActualOrCreateFromEnv();
267        if ($executionContext->getExecutingAction() !== ExecutionContext::SHOW_ACTION) {
268            return;
269        }
270
271
272        /**
273         * If the ID is a permalink, it is already the real id
274         * Why? because unfortunately, DOKUWIKI_STARTED is not the first event
275         * {@link action_plugin_combo_lang::load_lang()} may have already
276         * transformed a permalink into a real dokuwiki id
277         */
278        global $ID;
279        $page = MarkupPath::createMarkupFromId($ID);
280        if (FileSystems::exists($page)) {
281            return;
282        }
283
284        /**
285         * Doku Rewrite is not supported
286         */
287        $urlRewrite = Site::getUrlRewrite();
288        if ($urlRewrite == UrlRewrite::VALUE_DOKU_REWRITE) {
289            UrlRewrite::sendErrorMessage();
290            return;
291        }
292
293        /**
294         * Try to find a redirection
295         */
296        $router = new Router();
297        try {
298            $redirection = $router->getRedirection();
299        } catch (ExceptionSqliteNotAvailable $e) {
300            // no Sql Lite
301            return;
302        } catch (ExceptionNotFound $e) {
303            // no redirection
304            return;
305        } catch (Exception $e) {
306            // Error
307            LogUtility::error("An unexpected error has occurred while trying to get a redirection", LogUtility::SUPPORT_CANONICAL, $e);
308            return;
309        }
310
311
312        /**
313         * Special Mode where the redirection is just a change of ACT
314         */
315        if ($redirection->getOrigin() === Router::GO_TO_EDIT_MODE) {
316            global $ACT;
317            $ACT = 'edit';
318            return;
319        }
320
321        /**
322         * Other redirections
323         */
324        switch ($redirection->getType()) {
325            case RouterRedirection::REDIRECT_TRANSPARENT_METHOD:
326                try {
327                    $this->executeTransparentRedirect($redirection);
328                } catch (ExceptionCompile $e) {
329                    LogUtility::error("Internal Error: A transparent redirect errors has occurred", LogUtility::SUPPORT_CANONICAL, $e);
330                }
331                return;
332            default:
333                try {
334                    $this->executeHttpRedirect($redirection);
335                } catch (ExceptionCompile $e) {
336                    LogUtility::error("Internal Error: A http redirect errors has occurred", LogUtility::SUPPORT_CANONICAL, $e);
337                }
338        }
339
340
341    }
342
343
344    /**
345     * Redirect to an internal page ie:
346     *   * on the same domain
347     *   * no HTTP redirect
348     *   * id rewrite
349     * It happens when we use the id in the URL
350     * @param RouterRedirection $redirection - target page id
351     * @return void - return true if the user has the permission and that the redirect was done
352     * @throws ExceptionCompile
353     */
354    private
355    function executeTransparentRedirect(RouterRedirection $redirection): void
356    {
357        $markupPath = $redirection->getTargetMarkupPath();
358        if ($markupPath === null) {
359            throw new ExceptionCompile("A transparent redirect should have a wiki path. Origin {$redirection->getOrigin()}");
360        }
361        $targetPageId = $redirection->getTargetMarkupPath()->toAbsoluteId();
362
363        // If the user does not have the right to see the target page
364        // don't do anything
365        if (!(Identity::isReader($targetPageId))) {
366            return;
367        }
368
369        // Change the id
370        global $ID;
371        global $INFO;
372        $sourceId = $ID;
373        $ID = $targetPageId;
374        if (isset($_REQUEST["id"])) {
375            $_REQUEST["id"] = $targetPageId;
376        }
377        if (isset($_GET["id"])) {
378            $_GET["id"] = $targetPageId;
379        }
380
381        /**
382         * Refresh the $INFO data
383         *
384         * the info attributes are used elsewhere
385         *   'id': for the sidebar
386         *   'exist' : for the meta robot = noindex,follow, see {@link tpl_metaheaders()}
387         *   'rev' : for the edit button to be sure that the page is still the same
388         */
389        $INFO = pageinfo();
390
391        /**
392         * Not compatible with
393         * https://www.dokuwiki.org/config:send404 is enabled
394         *
395         * This check happens before that dokuwiki is started
396         * and send an header in doku.php
397         *
398         * We send a warning
399         */
400        global $conf;
401        if ($conf['send404']) {
402            LogUtility::msg("The <a href=\"https://www.dokuwiki.org/config:send404\">dokuwiki send404 configuration</a> is on and should be disabled when using the url manager", LogUtility::LVL_MSG_ERROR, self::CANONICAL);
403        }
404
405        // Redirection
406        $this->logRedirection($redirection);
407
408    }
409
410
411    /**
412     * The general HTTP Redirect method to an internal page
413     * where the redirection method decide which type of redirection
414     * @throws ExceptionCompile - if any error
415     */
416    private
417    function executeHttpRedirect(RouterRedirection $redirection): void
418    {
419
420
421        // Log the redirections
422        $this->logRedirection($redirection);
423
424
425        $targetUrl = $redirection->getTargetUrl();
426
427        if ($targetUrl !== null) {
428
429            // defend against HTTP Response Splitting
430            // https://owasp.org/www-community/attacks/HTTP_Response_Splitting
431            $targetUrl = stripctl($targetUrl->toAbsoluteUrlString());
432
433
434        } else {
435
436            global $ID;
437
438            // if this is search engine redirect
439            $url = UrlEndpoint::createDokuUrl();
440            switch ($redirection->getOrigin()) {
441                case RouterRedirection::TARGET_ORIGIN_SEARCH_ENGINE:
442                {
443                    $replacementPart = array(':', '_', '-');
444                    $query = str_replace($replacementPart, ' ', $ID);
445                    $url->setQueryParameter(ExecutionContext::DO_ATTRIBUTE, ExecutionContext::SEARCH_ACTION);
446                    $url->setQueryParameter("q", $query);
447                    $url->setQueryParameter(DokuwikiId::DOKUWIKI_ID_ATTRIBUTE, $ID);
448                    break;
449                }
450                default:
451
452                    $markupPath = $redirection->getTargetMarkupPath();
453                    if ($markupPath == null) {
454                        // should not happen (Both may be null but only on edit mode)
455                        throw new ExceptionCompile("Internal Error When executing a http redirect, the URL or the wiki page should not be null");
456                    }
457                    $url->setQueryParameter(DokuwikiId::DOKUWIKI_ID_ATTRIBUTE, $markupPath->toAbsoluteId());
458
459
460            }
461
462            /**
463             * Doing a permanent redirect with a added query string
464             * create a new page url on the search engine
465             *
466             * ie
467             * http://host/page
468             * is not the same
469             * than
470             * http://host/page?whatever
471             *
472             * We can't pass query string otherwise, we get
473             * the SEO warning / error
474             * `Alternative page with proper canonical tag`
475             *
476             * Use HTTP X header for debug
477             */
478            if ($redirection->getType() !== RouterRedirection::REDIRECT_PERMANENT_METHOD) {
479                $url->setQueryParameter(action_plugin_combo_routermessage::ORIGIN_PAGE, $ID);
480                $url->setQueryParameter(action_plugin_combo_routermessage::ORIGIN_TYPE, $redirection->getOrigin());
481            }
482
483
484            $targetUrl = $url->toAbsoluteUrlString();
485
486
487        }
488
489
490        /**
491         * Check that we are not redirecting to the same URL
492         * to avoid the TOO_MANY_REDIRECT error
493         */
494        $requestURL = Url::createFromString($_SERVER['REQUEST_URI'])->toAbsoluteUrlString();
495        if ($requestURL === $targetUrl) {
496            throw new ExceptionCompile("A redirection should not redirect to the requested URL. Redirection Origin: {$redirection->getOrigin()}, Redirection URL:{$targetUrl} ");
497        }
498
499        /**
500         * The dokuwiki function {@link send_redirect()}
501         * set the `Location header` and in php, the header function
502         * in this case change the status code to 302 Arghhhh.
503         * The code below is adapted from this function {@link send_redirect()}
504         */
505        global $MSG; // are there any undisplayed messages? keep them in session for display
506        if (isset($MSG) && count($MSG) && !defined('NOSESSION')) {
507            //reopen session, store data and close session again
508            @session_start();
509            $_SESSION[DOKU_COOKIE]['msg'] = $MSG;
510        }
511        session_write_close(); // always close the session
512
513        switch ($redirection->getType()) {
514
515            case RouterRedirection::REDIRECT_PERMANENT_METHOD:
516                ExecutionContext::getActualOrCreateFromEnv()
517                    ->response()
518                    ->setStatus(HttpResponseStatus::PERMANENT_REDIRECT)
519                    ->addHeader(self::LOCATION_HEADER_PREFIX . $targetUrl)
520                    ->end();
521                return;
522
523            case RouterRedirection::REDIRECT_NOTFOUND_METHOD:
524
525                // Empty 404 body to not get the standard 404 page of the browser
526                // but a blank page to avoid a sort of FOUC.
527                // ie the user see a page briefly
528                ExecutionContext::getActualOrCreateFromEnv()
529                    ->response()
530                    ->setStatus(HttpResponseStatus::NOT_FOUND)
531                    ->addHeader(self::REFRESH_HEADER_PREFIX . $targetUrl)
532                    ->setBody(self::PAGE_404, Mime::getHtml())
533                    ->end();
534                return;
535
536            default:
537                throw new ExceptionCompile("The type ({$redirection->getType()}) is not an http redirection");
538
539        }
540
541
542    }
543
544
545    /**
546     *
547     *   * For a conf file, it will update the Redirection Action Data as Referrer, Count Of Redirection, Redirection Date
548     *   * For a SQlite database, it will add a row into the log
549     *
550     * @param string $sourcePageId
551     * @param $targetPageId
552     * @param $algorithmic
553     * @param $method - http or rewrite
554     */
555    function logRedirection(RouterRedirection $redirection)
556    {
557        global $ID;
558
559        $row = array(
560            "TIMESTAMP" => date("c"),
561            "SOURCE" => $ID,
562            "TARGET" => $redirection->getTargetAsString(),
563            "REFERRER" => $_SERVER['HTTP_REFERER'] ?? null,
564            "TYPE" => $redirection->getOrigin(),
565            "METHOD" => $redirection->getType()
566        );
567        try {
568            $request = Sqlite::createOrGetBackendSqlite()
569                ->createRequest()
570                ->setTableRow('redirections_log', $row);
571        } catch (ExceptionSqliteNotAvailable $e) {
572            return;
573        }
574        try {
575            $request
576                ->execute();
577        } catch (ExceptionCompile $e) {
578            LogUtility::msg("Redirection Log Insert Error. {$e->getMessage()}");
579        } finally {
580            $request->close();
581        }
582
583
584    }
585
586
587}
588