1<?php 2/** 3 * Matomo - free/libre analytics platform 4 * 5 * @link https://matomo.org 6 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later 7 * 8 */ 9namespace Piwik\API; 10 11use Exception; 12use Piwik\Access; 13use Piwik\Cache; 14use Piwik\Common; 15use Piwik\Config; 16use Piwik\Container\StaticContainer; 17use Piwik\Context; 18use Piwik\DataTable; 19use Piwik\Exception\PluginDeactivatedException; 20use Piwik\IP; 21use Piwik\Piwik; 22use Piwik\Plugin\Manager as PluginManager; 23use Piwik\Plugins\CoreHome\LoginAllowlist; 24use Piwik\SettingsServer; 25use Piwik\Url; 26use Piwik\UrlHelper; 27use Psr\Log\LoggerInterface; 28 29/** 30 * Dispatches API requests to the appropriate API method. 31 * 32 * The Request class is used throughout Piwik to call API methods. The difference 33 * between using Request and calling API methods directly is that Request 34 * will do more after calling the API including: applying generic filters, applying queued filters, 35 * and handling the **flat** and **label** query parameters. 36 * 37 * Additionally, the Request class will **forward current query parameters** to the request 38 * which is more convenient than calling {@link Piwik\Common::getRequestVar()} many times over. 39 * 40 * In most cases, using a Request object to query the API is the correct approach. 41 * 42 * ### Post-processing 43 * 44 * The return value of API methods undergo some extra processing before being returned by Request. 45 * 46 * ### Output Formats 47 * 48 * The value returned by Request will be serialized to a certain format before being returned. 49 * 50 * ### Examples 51 * 52 * **Basic Usage** 53 * 54 * $request = new Request('method=UserLanguage.getLanguage&idSite=1&date=yesterday&period=week' 55 * . '&format=xml&filter_limit=5&filter_offset=0') 56 * $result = $request->process(); 57 * echo $result; 58 * 59 * **Getting a unrendered DataTable** 60 * 61 * // use the convenience method 'processRequest' 62 * $dataTable = Request::processRequest('UserLanguage.getLanguage', array( 63 * 'idSite' => 1, 64 * 'date' => 'yesterday', 65 * 'period' => 'week', 66 * 'filter_limit' => 5, 67 * 'filter_offset' => 0 68 * 69 * 'format' => 'original', // this is the important bit 70 * )); 71 * echo "This DataTable has " . $dataTable->getRowsCount() . " rows."; 72 * 73 * @see http://piwik.org/docs/analytics-api 74 * @api 75 */ 76class Request 77{ 78 /** 79 * The count of nested API request invocations. Used to determine if the currently executing request is the root or not. 80 * 81 * @var int 82 */ 83 private static $nestedApiInvocationCount = 0; 84 85 private $request = null; 86 87 /** 88 * Converts the supplied request string into an array of query parameter name/value 89 * mappings. The current query parameters (everything in `$_GET` and `$_POST`) are 90 * forwarded to request array before it is returned. 91 * 92 * @param string|array|null $request The base request string or array, eg, 93 * `'module=UserLanguage&action=getLanguage'`. 94 * @param array $defaultRequest Default query parameters. If a query parameter is absent in `$request`, it will be loaded 95 * from this. Defaults to `$_GET + $_POST`. 96 * @return array 97 */ 98 public static function getRequestArrayFromString($request, $defaultRequest = null) 99 { 100 if ($defaultRequest === null) { 101 $defaultRequest = self::getDefaultRequest(); 102 103 $requestRaw = self::getRequestParametersGET(); 104 if (!empty($requestRaw['segment'])) { 105 $defaultRequest['segment'] = $requestRaw['segment']; 106 } 107 108 if (!isset($defaultRequest['format_metrics'])) { 109 $defaultRequest['format_metrics'] = 'bc'; 110 } 111 } 112 113 $requestArray = $defaultRequest; 114 115 if (!is_null($request)) { 116 if (is_array($request)) { 117 $requestParsed = $request; 118 } else { 119 $request = trim($request); 120 $request = str_replace(array("\n", "\t"), '', $request); 121 122 $requestParsed = UrlHelper::getArrayFromQueryString($request); 123 } 124 125 $requestArray = $requestParsed + $defaultRequest; 126 } 127 128 foreach ($requestArray as &$element) { 129 if (!is_array($element)) { 130 $element = trim((string) $element); 131 } 132 } 133 return $requestArray; 134 } 135 136 /** 137 * Constructor. 138 * 139 * @param string|array $request Query string that defines the API call (must at least contain a **method** parameter), 140 * eg, `'method=UserLanguage.getLanguage&idSite=1&date=yesterday&period=week&format=xml'` 141 * If a request is not provided, then we use the values in the `$_GET` and `$_POST` 142 * superglobals. 143 * @param array $defaultRequest Default query parameters. If a query parameter is absent in `$request`, it will be loaded 144 * from this. Defaults to `$_GET + $_POST`. 145 */ 146 public function __construct($request = null, $defaultRequest = null) 147 { 148 $this->request = self::getRequestArrayFromString($request, $defaultRequest); 149 $this->sanitizeRequest(); 150 $this->renameModuleAndActionInRequest(); 151 } 152 153 /** 154 * For backward compatibility: Piwik API still works if module=Referers, 155 * we rewrite to correct renamed plugin: Referrers 156 * 157 * @param $module 158 * @param $action 159 * @return array( $module, $action ) 160 * @ignore 161 */ 162 public static function getRenamedModuleAndAction($module, $action) 163 { 164 /** 165 * This event is posted in the Request dispatcher and can be used 166 * to overwrite the Module and Action to dispatch. 167 * This is useful when some Controller methods or API methods have been renamed or moved to another plugin. 168 * 169 * @param $module string 170 * @param $action string 171 */ 172 Piwik::postEvent('Request.getRenamedModuleAndAction', array(&$module, &$action)); 173 174 return array($module, $action); 175 } 176 177 /** 178 * Make sure that the request contains no logical errors 179 */ 180 private function sanitizeRequest() 181 { 182 // The label filter does not work with expanded=1 because the data table IDs have a different meaning 183 // depending on whether the table has been loaded yet. expanded=1 causes all tables to be loaded, which 184 // is why the label filter can't descend when a recursive label has been requested. 185 // To fix this, we remove the expanded parameter if a label parameter is set. 186 if (isset($this->request['label']) && !empty($this->request['label']) 187 && isset($this->request['expanded']) && $this->request['expanded'] 188 ) { 189 unset($this->request['expanded']); 190 } 191 } 192 193 /** 194 * Dispatches the API request to the appropriate API method and returns the result 195 * after post-processing. 196 * 197 * Post-processing includes: 198 * 199 * - flattening if **flat** is 0 200 * - running generic filters unless **disable_generic_filters** is set to 1 201 * - URL decoding label column values 202 * - running queued filters unless **disable_queued_filters** is set to 1 203 * - removing columns based on the values of the **hideColumns** and **showColumns** query parameters 204 * - filtering rows if the **label** query parameter is set 205 * - converting the result to the appropriate format (ie, XML, JSON, etc.) 206 * 207 * If `'original'` is supplied for the output format, the result is returned as a PHP 208 * object. 209 * 210 * @throws PluginDeactivatedException if the module plugin is not activated. 211 * @throws Exception if the requested API method cannot be called, if required parameters for the 212 * API method are missing or if the API method throws an exception and the **format** 213 * query parameter is **original**. 214 * @return DataTable|Map|string The data resulting from the API call. 215 */ 216 public function process() 217 { 218 $shouldReloadAuth = false; 219 220 try { 221 ++self::$nestedApiInvocationCount; 222 223 // read the format requested for the output data 224 $outputFormat = strtolower(Common::getRequestVar('format', 'xml', 'string', $this->request)); 225 226 $disablePostProcessing = $this->shouldDisablePostProcessing(); 227 228 // create the response 229 $response = new ResponseBuilder($outputFormat, $this->request); 230 if ($disablePostProcessing) { 231 $response->disableDataTablePostProcessor(); 232 } 233 234 $corsHandler = new CORSHandler(); 235 $corsHandler->handle(); 236 237 $tokenAuth = Common::getRequestVar('token_auth', '', 'string', $this->request); 238 239 // IP check is needed here as we cannot listen to API.Request.authenticate as it would then not return proper API format response. 240 // We can also not do it by listening to API.Request.dispatch as by then the user is already authenticated and we want to make sure 241 // to not expose any information in case the IP is not allowed. 242 $list = new LoginAllowlist(); 243 if ($list->shouldCheckAllowlist() && $list->shouldAllowlistApplyToAPI()) { 244 $ip = IP::getIpFromHeader(); 245 $list->checkIsAllowed($ip); 246 } 247 248 // read parameters 249 $moduleMethod = Common::getRequestVar('method', null, 'string', $this->request); 250 251 list($module, $method) = $this->extractModuleAndMethod($moduleMethod); 252 list($module, $method) = self::getRenamedModuleAndAction($module, $method); 253 254 PluginManager::getInstance()->checkIsPluginActivated($module); 255 256 $apiClassName = self::getClassNameAPI($module); 257 258 if ($shouldReloadAuth = self::shouldReloadAuthUsingTokenAuth($this->request)) { 259 $access = Access::getInstance(); 260 $tokenAuthToRestore = $access->getTokenAuth(); 261 $hadSuperUserAccess = $access->hasSuperUserAccess(); 262 self::forceReloadAuthUsingTokenAuth($tokenAuth); 263 } 264 265 // call the method 266 $returnedValue = Proxy::getInstance()->call($apiClassName, $method, $this->request); 267 268 // get the response with the request query parameters loaded, since DataTablePost processor will use the Report 269 // class instance, which may inspect the query parameters. (eg, it may look for the idCustomReport parameters 270 // which may only exist in $this->request, if the request was called programmatically) 271 $toReturn = Context::executeWithQueryParameters($this->request, function () use ($response, $returnedValue, $module, $method) { 272 return $response->getResponse($returnedValue, $module, $method); 273 }); 274 } catch (Exception $e) { 275 StaticContainer::get(LoggerInterface::class)->error('Uncaught exception in API: {exception}', [ 276 'exception' => $e, 277 'ignoreInScreenWriter' => true, 278 ]); 279 280 if (empty($response)) { 281 $response = new ResponseBuilder('console', $this->request); 282 } 283 284 $toReturn = $response->getResponseException($e); 285 } finally { 286 --self::$nestedApiInvocationCount; 287 } 288 289 if ($shouldReloadAuth) { 290 $this->restoreAuthUsingTokenAuth($tokenAuthToRestore, $hadSuperUserAccess); 291 } 292 293 return $toReturn; 294 } 295 296 private function restoreAuthUsingTokenAuth($tokenToRestore, $hadSuperUserAccess) 297 { 298 // if we would not make sure to unset super user access, the tokenAuth would be not authenticated and any 299 // token would just keep super user access (eg if the token that was reloaded before had super user access) 300 Access::getInstance()->setSuperUserAccess(false); 301 302 // we need to restore by reloading the tokenAuth as some permissions could have been removed in the API 303 // request etc. Otherwise we could just store a clone of Access::getInstance() and restore here 304 self::forceReloadAuthUsingTokenAuth($tokenToRestore); 305 306 if ($hadSuperUserAccess && !Access::getInstance()->hasSuperUserAccess()) { 307 // we are in context of `doAsSuperUser()` and need to restore this behaviour 308 Access::getInstance()->setSuperUserAccess(true); 309 } 310 } 311 312 /** 313 * Returns the name of a plugin's API class by plugin name. 314 * 315 * @param string $plugin The plugin name, eg, `'Referrers'`. 316 * @return string The fully qualified API class name, eg, `'\Piwik\Plugins\Referrers\API'`. 317 */ 318 public static function getClassNameAPI($plugin) 319 { 320 return sprintf('\Piwik\Plugins\%s\API', $plugin); 321 } 322 323 /** 324 * @ignore 325 * @internal 326 * @param string $currentApiMethod 327 */ 328 public static function setIsRootRequestApiRequest($currentApiMethod) 329 { 330 Cache::getTransientCache()->save('API.setIsRootRequestApiRequest', $currentApiMethod); 331 } 332 333 /** 334 * @ignore 335 * @internal 336 * @return string current Api Method if it is an api request 337 */ 338 public static function getRootApiRequestMethod() 339 { 340 return Cache::getTransientCache()->fetch('API.setIsRootRequestApiRequest'); 341 } 342 343 /** 344 * Detect if the root request (the actual request) is an API request or not. To detect whether an API is currently 345 * request within any request, have a look at {@link isApiRequest()}. 346 * 347 * @return bool 348 * @throws Exception 349 */ 350 public static function isRootRequestApiRequest() 351 { 352 $apiMethod = Cache::getTransientCache()->fetch('API.setIsRootRequestApiRequest'); 353 return !empty($apiMethod); 354 } 355 356 /** 357 * Checks if the currently executing API request is the root API request or not. 358 * 359 * Note: the "root" API request is the first request made. Within that request, further API methods 360 * can be called programmatically. These requests are considered "child" API requests. 361 * 362 * @return bool 363 * @throws Exception 364 */ 365 public static function isCurrentApiRequestTheRootApiRequest() 366 { 367 return self::$nestedApiInvocationCount == 1; 368 } 369 370 /** 371 * Detect if request is an API request. Meaning the module is 'API' and an API method having a valid format was 372 * specified. Note that this method will return true even if the actual request is for example a regular UI 373 * reporting page request but within this request we are currently processing an API request (eg a 374 * controller calls Request::processRequest('API.getMatomoVersion')). To find out if the root request is an API 375 * request or not, call {@link isRootRequestApiRequest()} 376 * 377 * @param array $request eg array('module' => 'API', 'method' => 'Test.getMethod') 378 * @return bool 379 * @throws Exception 380 */ 381 public static function isApiRequest($request) 382 { 383 $method = self::getMethodIfApiRequest($request); 384 return !empty($method); 385 } 386 387 /** 388 * Returns the current API method being executed, if the current request is an API request. 389 * 390 * @param array $request eg array('module' => 'API', 'method' => 'Test.getMethod') 391 * @return string|null 392 * @throws Exception 393 */ 394 public static function getMethodIfApiRequest($request) 395 { 396 $module = Common::getRequestVar('module', '', 'string', $request); 397 $method = Common::getRequestVar('method', '', 'string', $request); 398 399 $isApi = $module === 'API' && !empty($method) && (count(explode('.', $method)) === 2); 400 return $isApi ? $method : null; 401 } 402 403 /** 404 * If the token_auth is found in the $request parameter, 405 * the current session will be authenticated using this token_auth. 406 * It will overwrite the previous Auth object. 407 * 408 * @param array $request If null, uses the default request ($_GET) 409 * @return void 410 * @ignore 411 */ 412 public static function reloadAuthUsingTokenAuth($request = null) 413 { 414 // if a token_auth is specified in the API request, we load the right permissions 415 $token_auth = Common::getRequestVar('token_auth', '', 'string', $request); 416 417 if (self::shouldReloadAuthUsingTokenAuth($request)) { 418 self::forceReloadAuthUsingTokenAuth($token_auth); 419 } 420 } 421 422 /** 423 * The current session will be authenticated using this token_auth. 424 * It will overwrite the previous Auth object. 425 * 426 * @param string $tokenAuth 427 * @return void 428 */ 429 private static function forceReloadAuthUsingTokenAuth($tokenAuth) 430 { 431 /** 432 * Triggered when authenticating an API request, but only if the **token_auth** 433 * query parameter is found in the request. 434 * 435 * Plugins that provide authentication capabilities should subscribe to this event 436 * and make sure the global authentication object (the object returned by `StaticContainer::get('Piwik\Auth')`) 437 * is setup to use `$token_auth` when its `authenticate()` method is executed. 438 * 439 * @param string $token_auth The value of the **token_auth** query parameter. 440 */ 441 Piwik::postEvent('API.Request.authenticate', array($tokenAuth)); 442 if (!Access::getInstance()->reloadAccess() && $tokenAuth && $tokenAuth !== 'anonymous') { 443 /** 444 * @ignore 445 * @internal 446 */ 447 Piwik::postEvent('API.Request.authenticate.failed'); 448 } 449 SettingsServer::raiseMemoryLimitIfNecessary(); 450 } 451 452 /** 453 * Needs to be called AFTER the user has been authenticated using a token. 454 * 455 * @internal 456 * @ignore 457 * @param string $module 458 * @param string $action 459 * @throws Exception 460 */ 461 public static function checkTokenAuthIsNotLimited($module, $action) 462 { 463 $isApi = ($module === 'API' && (empty($action) || $action === 'index')); 464 if ($isApi 465 || Common::isPhpCliMode() 466 ) { 467 return; 468 } 469 470 if (Access::getInstance()->hasSuperUserAccess()) { 471 $ex = new \Piwik\Exception\Exception(Piwik::translate('Widgetize_TooHighAccessLevel', ['<a href="https://matomo.org/faq/troubleshooting/faq_147/" rel="noreferrer noopener">', '</a>'])); 472 $ex->setIsHtmlMessage(); 473 throw $ex; 474 } 475 476 $allowWriteAmin = Config::getInstance()->General['enable_framed_allow_write_admin_token_auth'] == 1; 477 if (Piwik::isUserHasSomeWriteAccess() 478 && !$allowWriteAmin 479 ) { 480 // we allow UI authentication/ embedding widgets / reports etc only for users that have only view 481 // access. it's mostly there to get users to use auth tokens of view users when embedding reports 482 // token_auth is fine for API calls since they would be always authenticated later anyway 483 // token_auth is also fine in CLI mode as eg doAsSuperUser might be used etc 484 // 485 // NOTE: this does not apply if the [General] enable_framed_allow_write_admin_token_auth INI 486 // option is set. 487 throw new \Exception(Piwik::translate('Widgetize_ViewAccessRequired', ['https://matomo.org/faq/troubleshooting/faq_147/'])); 488 } 489 } 490 491 /** 492 * @internal 493 * @ignore 494 * @param $request 495 * @return bool 496 * @throws Exception 497 */ 498 public static function shouldReloadAuthUsingTokenAuth($request) 499 { 500 if (is_null($request)) { 501 $request = self::getDefaultRequest(); 502 } 503 504 if (!isset($request['token_auth'])) { 505 // no token is given so we just keep the current loaded user 506 return false; 507 } 508 509 // a token is specified, we need to reload auth in case it is different than the current one, even if it is empty 510 $tokenAuth = Common::getRequestVar('token_auth', '', 'string', $request); 511 512 // not using !== is on purpose as getTokenAuth() might return null whereas $tokenAuth is '' . In this case 513 // we do not need to reload. 514 515 return $tokenAuth != Access::getInstance()->getTokenAuth(); 516 } 517 518 /** 519 * Returns array($class, $method) from the given string $class.$method 520 * 521 * @param string $parameter 522 * @throws Exception 523 * @return array 524 */ 525 private function extractModuleAndMethod($parameter) 526 { 527 $a = explode('.', $parameter); 528 if (count($a) != 2) { 529 throw new Exception("The method name is invalid. Expected 'module.methodName'"); 530 } 531 return $a; 532 } 533 534 /** 535 * Helper method that processes an API request in one line using the variables in `$_GET` 536 * and `$_POST`. 537 * 538 * @param string $method The API method to call, ie, `'Actions.getPageTitles'`. 539 * @param array $paramOverride The parameter name-value pairs to use instead of what's 540 * in `$_GET` & `$_POST`. 541 * @param array $defaultRequest Default query parameters. If a query parameter is absent in `$request`, it will be loaded 542 * from this. Defaults to `$_GET + $_POST`. 543 * 544 * To avoid using any parameters from $_GET or $_POST, set this to an empty `array()`. 545 * @return mixed The result of the API request. See {@link process()}. 546 */ 547 public static function processRequest($method, $paramOverride = array(), $defaultRequest = null) 548 { 549 $params = array(); 550 $params['format'] = 'original'; 551 $params['serialize'] = '0'; 552 $params['module'] = 'API'; 553 $params['method'] = $method; 554 $params['compare'] = '0'; 555 $params = $paramOverride + $params; 556 557 // process request 558 $request = new Request($params, $defaultRequest); 559 return $request->process(); 560 } 561 562 /** 563 * Returns the original request parameters in the current query string as an array mapping 564 * query parameter names with values. The result of this function will not be affected 565 * by any modifications to `$_GET` and will not include parameters in `$_POST`. 566 * 567 * @return array 568 */ 569 public static function getRequestParametersGET() 570 { 571 if (empty($_SERVER['QUERY_STRING'])) { 572 return array(); 573 } 574 $GET = UrlHelper::getArrayFromQueryString($_SERVER['QUERY_STRING']); 575 return $GET; 576 } 577 578 /** 579 * Returns the URL for the current requested report w/o any filter parameters. 580 * 581 * @param string $module The API module. 582 * @param string $action The API action. 583 * @param array $queryParams Query parameter overrides. 584 * @return string 585 */ 586 public static function getBaseReportUrl($module, $action, $queryParams = array()) 587 { 588 $params = array_merge($queryParams, array('module' => $module, 'action' => $action)); 589 return Request::getCurrentUrlWithoutGenericFilters($params); 590 } 591 592 /** 593 * Returns the current URL without generic filter query parameters. 594 * 595 * @param array $params Query parameter values to override in the new URL. 596 * @return string 597 */ 598 public static function getCurrentUrlWithoutGenericFilters($params) 599 { 600 // unset all filter query params so the related report will show up in its default state, 601 // unless the filter param was in $queryParams 602 $genericFiltersInfo = DataTableGenericFilter::getGenericFiltersInformation(); 603 foreach ($genericFiltersInfo as $filter) { 604 foreach ($filter[1] as $queryParamName => $queryParamInfo) { 605 if (!isset($params[$queryParamName])) { 606 $params[$queryParamName] = null; 607 } 608 } 609 } 610 611 $params['compareDates'] = null; 612 $params['comparePeriods'] = null; 613 $params['compareSegments'] = null; 614 615 return Url::getCurrentQueryStringWithParametersModified($params); 616 } 617 618 /** 619 * Returns whether the DataTable result will have to be expanded for the 620 * current request before rendering. 621 * 622 * @return bool 623 * @ignore 624 */ 625 public static function shouldLoadExpanded() 626 { 627 // if filter_column_recursive & filter_pattern_recursive are supplied, and flat isn't supplied 628 // we have to load all the child subtables. 629 return Common::getRequestVar('filter_column_recursive', false) !== false 630 && Common::getRequestVar('filter_pattern_recursive', false) !== false 631 && !self::shouldLoadFlatten(); 632 } 633 634 /** 635 * @return bool 636 */ 637 public static function shouldLoadFlatten() 638 { 639 return Common::getRequestVar('flat', false) == 1; 640 } 641 642 /** 643 * Returns the segment query parameter from the original request, without modifications. 644 * 645 * @return array|bool 646 */ 647 public static function getRawSegmentFromRequest() 648 { 649 // we need the URL encoded segment parameter, we fetch it from _SERVER['QUERY_STRING'] instead of default URL decoded _GET 650 $segmentRaw = false; 651 $segment = Common::getRequestVar('segment', '', 'string'); 652 if (!empty($segment)) { 653 $request = Request::getRequestParametersGET(); 654 if (!empty($request['segment'])) { 655 $segmentRaw = $request['segment']; 656 } 657 } 658 return $segmentRaw; 659 } 660 661 private function renameModuleAndActionInRequest() 662 { 663 if (empty($this->request['apiModule'])) { 664 return; 665 } 666 if (empty($this->request['apiAction'])) { 667 $this->request['apiAction'] = null; 668 } 669 list($this->request['apiModule'], $this->request['apiAction']) = $this->getRenamedModuleAndAction($this->request['apiModule'], $this->request['apiAction']); 670 } 671 672 /** 673 * @return array 674 */ 675 private static function getDefaultRequest() 676 { 677 return $_GET + $_POST; 678 } 679 680 private function shouldDisablePostProcessing() 681 { 682 $shouldDisable = false; 683 684 /** 685 * After an API method returns a value, the value is post processed (eg, rows are sorted 686 * based on the `filter_sort_column` query parameter, rows are truncated based on the 687 * `filter_limit`/`filter_offset` parameters, amongst other things). 688 * 689 * If you're creating a plugin that needs to disable post processing entirely for 690 * certain requests, use this event. 691 * 692 * @param bool &$shouldDisable Set this to true to disable datatable post processing for a request. 693 * @param array $request The request parameters. 694 */ 695 Piwik::postEvent('Request.shouldDisablePostProcessing', [&$shouldDisable, $this->request]); 696 697 if (!$shouldDisable) { 698 $shouldDisable = self::isCurrentApiRequestTheRootApiRequest() && 699 Common::getRequestVar('disable_root_datatable_post_processor', 0, 'int', $this->request) == 1; 700 } 701 702 return $shouldDisable; 703 } 704} 705