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