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 */ 9 10namespace Piwik\API; 11 12use Exception; 13use Piwik\Common; 14use Piwik\Container\StaticContainer; 15use Piwik\Context; 16use Piwik\Piwik; 17use Piwik\Plugin\Manager; 18use ReflectionClass; 19use ReflectionMethod; 20 21// prevent upgrade error eg from Matomo 3.x to Matomo 4.x. Refs https://github.com/matomo-org/matomo/pull/16468 22// the `false` is important otherwise it would fail and try to load the proxy.php file again. 23if (!class_exists('Piwik\API\NoDefaultValue', false)) { 24 class NoDefaultValue 25 { 26 } 27} 28 29/** 30 * Proxy is a singleton that has the knowledge of every method available, their parameters 31 * and default values. 32 * Proxy receives all the API calls requests via call() and forwards them to the right 33 * object, with the parameters in the right order. 34 * 35 * It will also log the performance of API calls (time spent, parameter values, etc.) if logger available 36 */ 37class Proxy 38{ 39 // array of already registered plugins names 40 protected $alreadyRegistered = array(); 41 42 protected $metadataArray = array(); 43 private $hideIgnoredFunctions = true; 44 45 // when a parameter doesn't have a default value we use this 46 private $noDefaultValue; 47 48 public function __construct() 49 { 50 $this->noDefaultValue = new NoDefaultValue(); 51 } 52 53 public static function getInstance() 54 { 55 return StaticContainer::get(self::class); 56 } 57 58 /** 59 * Returns array containing reflection meta data for all the loaded classes 60 * eg. number of parameters, method names, etc. 61 * 62 * @return array 63 */ 64 public function getMetadata() 65 { 66 ksort($this->metadataArray); 67 return $this->metadataArray; 68 } 69 70 /** 71 * Registers the API information of a given module. 72 * 73 * The module to be registered must be 74 * - a singleton (providing a getInstance() method) 75 * - the API file must be located in plugins/ModuleName/API.php 76 * for example plugins/Referrers/API.php 77 * 78 * The method will introspect the methods, their parameters, etc. 79 * 80 * @param string $className ModuleName eg. "API" 81 */ 82 public function registerClass($className) 83 { 84 if (isset($this->alreadyRegistered[$className])) { 85 return; 86 } 87 $this->includeApiFile($className); 88 $this->checkClassIsSingleton($className); 89 90 $rClass = new ReflectionClass($className); 91 if (!$this->shouldHideAPIMethod($rClass->getDocComment())) { 92 foreach ($rClass->getMethods() as $method) { 93 $this->loadMethodMetadata($className, $method); 94 } 95 96 $this->setDocumentation($rClass, $className); 97 $this->alreadyRegistered[$className] = true; 98 } 99 } 100 101 /** 102 * Will be displayed in the API page 103 * 104 * @param ReflectionClass $rClass Instance of ReflectionClass 105 * @param string $className Name of the class 106 */ 107 private function setDocumentation($rClass, $className) 108 { 109 // Doc comment 110 $doc = $rClass->getDocComment(); 111 $doc = str_replace(" * " . PHP_EOL, "<br>", $doc); 112 113 // boldify the first line only if there is more than one line, otherwise too much bold 114 if (substr_count($doc, '<br>') > 1) { 115 $firstLineBreak = strpos($doc, "<br>"); 116 $doc = "<div class='apiFirstLine'>" . substr($doc, 0, $firstLineBreak) . "</div>" . substr($doc, $firstLineBreak + strlen("<br>")); 117 } 118 $doc = preg_replace("/(@package)[a-z _A-Z]*/", "", $doc); 119 $doc = preg_replace("/(@method).*/", "", $doc); 120 $doc = str_replace(array("\t", "\n", "/**", "*/", " * ", " *", " ", "\t*", " * @package"), " ", $doc); 121 122 // replace 'foo' and `bar` and "foobar" with code blocks... much magic 123 $doc = preg_replace('/`(.*?)`/', '<code>$1</code>', $doc); 124 $this->metadataArray[$className]['__documentation'] = $doc; 125 } 126 127 /** 128 * Returns number of classes already loaded 129 * @return int 130 */ 131 public function getCountRegisteredClasses() 132 { 133 return count($this->alreadyRegistered); 134 } 135 136 /** 137 * Will execute $className->$methodName($parametersValues) 138 * If any error is detected (wrong number of parameters, method not found, class not found, etc.) 139 * it will throw an exception 140 * 141 * It also logs the API calls, with the parameters values, the returned value, the performance, etc. 142 * You can enable logging in config/global.ini.php (log_api_call) 143 * 144 * @param string $className The class name (eg. API) 145 * @param string $methodName The method name 146 * @param array $parametersRequest The parameters pairs (name=>value) 147 * 148 * @return mixed|null 149 * @throws Exception|\Piwik\NoAccessException 150 */ 151 public function call($className, $methodName, $parametersRequest) 152 { 153 // Temporarily sets the Request array to this API call context 154 return Context::executeWithQueryParameters($parametersRequest, function () use ($className, $methodName, $parametersRequest) { 155 $returnedValue = null; 156 157 $this->registerClass($className); 158 159 // instantiate the object 160 $object = $className::getInstance(); 161 162 // check method exists 163 $this->checkMethodExists($className, $methodName); 164 165 // get the list of parameters required by the method 166 $parameterNamesDefaultValues = $this->getParametersList($className, $methodName); 167 168 // load parameters in the right order, etc. 169 $finalParameters = $this->getRequestParametersArray($parameterNamesDefaultValues, $parametersRequest); 170 171 // allow plugins to manipulate the value 172 $pluginName = $this->getModuleNameFromClassName($className); 173 174 $returnedValue = null; 175 176 /** 177 * Triggered before an API request is dispatched. 178 * 179 * This event can be used to modify the arguments passed to one or more API methods. 180 * 181 * **Example** 182 * 183 * Piwik::addAction('API.Request.dispatch', function (&$parameters, $pluginName, $methodName) { 184 * if ($pluginName == 'Actions') { 185 * if ($methodName == 'getPageUrls') { 186 * // ... do something ... 187 * } else { 188 * // ... do something else ... 189 * } 190 * } 191 * }); 192 * 193 * @param array &$finalParameters List of parameters that will be passed to the API method. 194 * @param string $pluginName The name of the plugin the API method belongs to. 195 * @param string $methodName The name of the API method that will be called. 196 */ 197 Piwik::postEvent('API.Request.dispatch', array(&$finalParameters, $pluginName, $methodName)); 198 199 /** 200 * Triggered before an API request is dispatched. 201 * 202 * This event exists for convenience and is triggered directly after the {@hook API.Request.dispatch} 203 * event is triggered. It can be used to modify the arguments passed to a **single** API method. 204 * 205 * _Note: This is can be accomplished with the {@hook API.Request.dispatch} event as well, however 206 * event handlers for that event will have to do more work._ 207 * 208 * **Example** 209 * 210 * Piwik::addAction('API.Actions.getPageUrls', function (&$parameters) { 211 * // force use of a single website. for some reason. 212 * $parameters['idSite'] = 1; 213 * }); 214 * 215 * @param array &$finalParameters List of parameters that will be passed to the API method. 216 */ 217 Piwik::postEvent(sprintf('API.%s.%s', $pluginName, $methodName), array(&$finalParameters)); 218 219 /** 220 * Triggered before an API request is dispatched. 221 * 222 * Use this event to intercept an API request and execute your own code instead. If you set 223 * `$returnedValue` in a handler for this event, the original API method will not be executed, 224 * and the result will be what you set in the event handler. 225 * 226 * @param mixed &$returnedValue Set this to set the result and preempt normal API invocation. 227 * @param array &$finalParameters List of parameters that will be passed to the API method. 228 * @param string $pluginName The name of the plugin the API method belongs to. 229 * @param string $methodName The name of the API method that will be called. 230 * @param array $parametersRequest The query parameters for this request. 231 */ 232 Piwik::postEvent('API.Request.intercept', [&$returnedValue, $finalParameters, $pluginName, $methodName, $parametersRequest]); 233 234 $apiParametersInCorrectOrder = array(); 235 236 foreach ($parameterNamesDefaultValues as $name => $defaultValue) { 237 if (isset($finalParameters[$name]) || array_key_exists($name, $finalParameters)) { 238 $apiParametersInCorrectOrder[] = $finalParameters[$name]; 239 } 240 } 241 242 // call the method if a hook hasn't already set an output variable 243 if ($returnedValue === null) { 244 $returnedValue = call_user_func_array(array($object, $methodName), $apiParametersInCorrectOrder); 245 } 246 247 $endHookParams = array( 248 &$returnedValue, 249 array('className' => $className, 250 'module' => $pluginName, 251 'action' => $methodName, 252 'parameters' => $finalParameters) 253 ); 254 255 /** 256 * Triggered directly after an API request is dispatched. 257 * 258 * This event exists for convenience and is triggered immediately before the 259 * {@hook API.Request.dispatch.end} event. It can be used to modify the output of a **single** 260 * API method. 261 * 262 * _Note: This can be accomplished with the {@hook API.Request.dispatch.end} event as well, 263 * however event handlers for that event will have to do more work._ 264 * 265 * **Example** 266 * 267 * // append (0 hits) to the end of row labels whose row has 0 hits 268 * Piwik::addAction('API.Actions.getPageUrls', function (&$returnValue, $info)) { 269 * $returnValue->filter('ColumnCallbackReplace', 'label', function ($label, $hits) { 270 * if ($hits === 0) { 271 * return $label . " (0 hits)"; 272 * } else { 273 * return $label; 274 * } 275 * }, null, array('nb_hits')); 276 * } 277 * 278 * @param mixed &$returnedValue The API method's return value. Can be an object, such as a 279 * {@link Piwik\DataTable DataTable} instance. 280 * could be a {@link Piwik\DataTable DataTable}. 281 * @param array $extraInfo An array holding information regarding the API request. Will 282 * contain the following data: 283 * 284 * - **className**: The namespace-d class name of the API instance 285 * that's being called. 286 * - **module**: The name of the plugin the API request was 287 * dispatched to. 288 * - **action**: The name of the API method that was executed. 289 * - **parameters**: The array of parameters passed to the API 290 * method. 291 */ 292 Piwik::postEvent(sprintf('API.%s.%s.end', $pluginName, $methodName), $endHookParams); 293 294 /** 295 * Triggered directly after an API request is dispatched. 296 * 297 * This event can be used to modify the output of any API method. 298 * 299 * **Example** 300 * 301 * // append (0 hits) to the end of row labels whose row has 0 hits for any report that has the 'nb_hits' metric 302 * Piwik::addAction('API.Actions.getPageUrls.end', function (&$returnValue, $info)) { 303 * // don't process non-DataTable reports and reports that don't have the nb_hits column 304 * if (!($returnValue instanceof DataTableInterface) 305 * || in_array('nb_hits', $returnValue->getColumns()) 306 * ) { 307 * return; 308 * } 309 * 310 * $returnValue->filter('ColumnCallbackReplace', 'label', function ($label, $hits) { 311 * if ($hits === 0) { 312 * return $label . " (0 hits)"; 313 * } else { 314 * return $label; 315 * } 316 * }, null, array('nb_hits')); 317 * } 318 * 319 * @param mixed &$returnedValue The API method's return value. Can be an object, such as a 320 * {@link Piwik\DataTable DataTable} instance. 321 * @param array $extraInfo An array holding information regarding the API request. Will 322 * contain the following data: 323 * 324 * - **className**: The namespace-d class name of the API instance 325 * that's being called. 326 * - **module**: The name of the plugin the API request was 327 * dispatched to. 328 * - **action**: The name of the API method that was executed. 329 * - **parameters**: The array of parameters passed to the API 330 * method. 331 */ 332 Piwik::postEvent('API.Request.dispatch.end', $endHookParams); 333 334 return $returnedValue; 335 }); 336 } 337 338 /** 339 * Returns the parameters names and default values for the method $name 340 * of the class $class 341 * 342 * @param string $class The class name 343 * @param string $name The method name 344 * @return array Format array( 345 * 'testParameter' => null, // no default value 346 * 'life' => 42, // default value = 42 347 * 'date' => 'yesterday', 348 * ); 349 */ 350 public function getParametersList($class, $name) 351 { 352 return $this->metadataArray[$class][$name]['parameters']; 353 } 354 355 /** 356 * Check if given method name is deprecated or not. 357 */ 358 public function isDeprecatedMethod($class, $methodName) 359 { 360 return $this->metadataArray[$class][$methodName]['isDeprecated']; 361 } 362 363 /** 364 * Returns the 'moduleName' part of '\\Piwik\\Plugins\\moduleName\\API' 365 * 366 * @param string $className "API" 367 * @return string "Referrers" 368 */ 369 public function getModuleNameFromClassName($className) 370 { 371 return str_replace(array('\\Piwik\\Plugins\\', '\\API'), '', $className); 372 } 373 374 public function isExistingApiAction($pluginName, $apiAction) 375 { 376 $namespacedApiClassName = "\\Piwik\\Plugins\\$pluginName\\API"; 377 $api = $namespacedApiClassName::getInstance(); 378 379 return method_exists($api, $apiAction); 380 } 381 382 public function buildApiActionName($pluginName, $apiAction) 383 { 384 return sprintf("%s.%s", $pluginName, $apiAction); 385 } 386 387 /** 388 * Sets whether to hide '@ignore'd functions from method metadata or not. 389 * 390 * @param bool $hideIgnoredFunctions 391 */ 392 public function setHideIgnoredFunctions($hideIgnoredFunctions) 393 { 394 $this->hideIgnoredFunctions = $hideIgnoredFunctions; 395 396 // make sure metadata gets reloaded 397 $this->alreadyRegistered = array(); 398 $this->metadataArray = array(); 399 } 400 401 /** 402 * Returns an array containing the values of the parameters to pass to the method to call 403 * 404 * @param array $requiredParameters array of (parameter name, default value) 405 * @param array $parametersRequest 406 * @throws Exception 407 * @return array values to pass to the function call 408 */ 409 private function getRequestParametersArray($requiredParameters, $parametersRequest) 410 { 411 $finalParameters = array(); 412 foreach ($requiredParameters as $name => $defaultValue) { 413 try { 414 if ($defaultValue instanceof NoDefaultValue) { 415 $requestValue = Common::getRequestVar($name, null, null, $parametersRequest); 416 } else { 417 try { 418 if ($name == 'segment' && !empty($parametersRequest['segment'])) { 419 // segment parameter is an exception: we do not want to sanitize user input or it would break the segment encoding 420 $requestValue = ($parametersRequest['segment']); 421 } else { 422 $requestValue = Common::getRequestVar($name, $defaultValue, null, $parametersRequest); 423 } 424 } catch (Exception $e) { 425 // Special case: empty parameter in the URL, should return the empty string 426 if (isset($parametersRequest[$name]) 427 && $parametersRequest[$name] === '' 428 ) { 429 $requestValue = ''; 430 } else { 431 $requestValue = $defaultValue; 432 } 433 } 434 } 435 } catch (Exception $e) { 436 throw new Exception(Piwik::translate('General_PleaseSpecifyValue', array($name))); 437 } 438 $finalParameters[$name] = $requestValue; 439 } 440 return $finalParameters; 441 } 442 443 /** 444 * Includes the class API by looking up plugins/xxx/API.php 445 * 446 * @param string $fileName api class name eg. "API" 447 * @throws Exception 448 */ 449 private function includeApiFile($fileName) 450 { 451 $module = self::getModuleNameFromClassName($fileName); 452 $path = Manager::getPluginDirectory($module) . '/API.php'; 453 454 if (is_readable($path)) { 455 require_once $path; // prefixed by PIWIK_INCLUDE_PATH 456 } else { 457 throw new Exception("API module $module not found."); 458 } 459 } 460 461 /** 462 * @param string $class name of a class 463 * @param ReflectionMethod $method instance of ReflectionMethod 464 */ 465 private function loadMethodMetadata($class, $method) 466 { 467 if (!$this->checkIfMethodIsAvailable($method)) { 468 return; 469 } 470 $name = $method->getName(); 471 $parameters = $method->getParameters(); 472 $docComment = $method->getDocComment(); 473 474 $aParameters = array(); 475 foreach ($parameters as $parameter) { 476 $nameVariable = $parameter->getName(); 477 478 $defaultValue = $this->noDefaultValue; 479 if ($parameter->isDefaultValueAvailable()) { 480 $defaultValue = $parameter->getDefaultValue(); 481 } 482 483 $aParameters[$nameVariable] = $defaultValue; 484 } 485 $this->metadataArray[$class][$name]['parameters'] = $aParameters; 486 $this->metadataArray[$class][$name]['numberOfRequiredParameters'] = $method->getNumberOfRequiredParameters(); 487 $this->metadataArray[$class][$name]['isDeprecated'] = false !== strstr($docComment, '@deprecated'); 488 } 489 490 /** 491 * Checks that the method exists in the class 492 * 493 * @param string $className The class name 494 * @param string $methodName The method name 495 * @throws Exception If the method is not found 496 */ 497 private function checkMethodExists($className, $methodName) 498 { 499 if (!$this->isMethodAvailable($className, $methodName)) { 500 throw new Exception(Piwik::translate('General_ExceptionMethodNotFound', array($methodName, $className))); 501 } 502 } 503 504 /** 505 * @param $docComment 506 * @return bool 507 */ 508 public function shouldHideAPIMethod($docComment) 509 { 510 $hideLine = strstr($docComment, '@hide'); 511 512 if ($hideLine === false) { 513 return false; 514 } 515 516 $hideLine = trim($hideLine); 517 $hideLine .= ' '; 518 519 $token = trim(strtok($hideLine, " "), "\n"); 520 521 $hide = false; 522 523 if (!empty($token)) { 524 /** 525 * This event exists for checking whether a Plugin API class or a Plugin API method tagged 526 * with a `@hideXYZ` should be hidden in the API listing. 527 * 528 * @param bool &$hide whether to hide APIs tagged with $token should be displayed. 529 */ 530 Piwik::postEvent(sprintf('API.DocumentationGenerator.%s', $token), array(&$hide)); 531 } 532 533 return $hide; 534 } 535 536 /** 537 * @param ReflectionMethod $method 538 * @return bool 539 */ 540 protected function checkIfMethodIsAvailable(ReflectionMethod $method) 541 { 542 if (!$method->isPublic() || $method->isConstructor() || $method->getName() === 'getInstance') { 543 return false; 544 } 545 546 if ($this->hideIgnoredFunctions && false !== strstr($method->getDocComment(), '@ignore')) { 547 return false; 548 } 549 550 if ($this->shouldHideAPIMethod($method->getDocComment())) { 551 return false; 552 } 553 554 return true; 555 } 556 557 /** 558 * Returns true if the method is found in the API of the given class name. 559 * 560 * @param string $className The class name 561 * @param string $methodName The method name 562 * @return bool 563 */ 564 private function isMethodAvailable($className, $methodName) 565 { 566 return isset($this->metadataArray[$className][$methodName]); 567 } 568 569 /** 570 * Checks that the class is a Singleton (presence of the getInstance() method) 571 * 572 * @param string $className The class name 573 * @throws Exception If the class is not a Singleton 574 */ 575 private function checkClassIsSingleton($className) 576 { 577 if (!method_exists($className, "getInstance")) { 578 throw new Exception("$className that provide an API must be Singleton and have a 'public static function getInstance()' method."); 579 } 580 } 581} 582