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