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