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