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