1<?php
2
3namespace dokuwiki\Remote;
4
5use dokuwiki\Extension\PluginInterface;
6use dokuwiki\Input\Input;
7use dokuwiki\Extension\Event;
8use dokuwiki\Extension\RemotePlugin;
9
10/**
11 * This class provides information about remote access to the wiki.
12 *
13 * == Types of methods ==
14 * There are two types of remote methods. The first is the core methods.
15 * These are always available and provided by dokuwiki.
16 * The other is plugin methods. These are provided by remote plugins.
17 *
18 * == Information structure ==
19 * The information about methods will be given in an array with the following structure:
20 * array(
21 *     'method.remoteName' => array(
22 *          'args' => array(
23 *              'type eg. string|int|...|date|file',
24 *          )
25 *          'name' => 'method name in class',
26 *          'return' => 'type',
27 *          'public' => 1/0 - method bypass default group check (used by login)
28 *          ['doc' = 'method documentation'],
29 *     )
30 * )
31 *
32 * plugin names are formed the following:
33 *   core methods begin by a 'dokuwiki' or 'wiki' followed by a . and the method name itself.
34 *   i.e.: dokuwiki.version or wiki.getPage
35 *
36 * plugin methods are formed like 'plugin.<plugin name>.<method name>'.
37 * i.e.: plugin.clock.getTime or plugin.clock_gmt.getTime
38 */
39class Api
40{
41    /**
42     * @var ApiCore|\RemoteAPICoreTest
43     */
44    private $coreMethods;
45
46    /**
47     * @var array remote methods provided by dokuwiki plugins - will be filled lazy via
48     * {@see dokuwiki\Remote\RemoteAPI#getPluginMethods}
49     */
50    private $pluginMethods;
51
52    /**
53     * @var array contains custom calls to the api. Plugins can use the XML_CALL_REGISTER event.
54     * The data inside is 'custom.call.something' => array('plugin name', 'remote method name')
55     *
56     * The remote method name is the same as in the remote name returned by _getMethods().
57     */
58    private $pluginCustomCalls;
59
60    private $dateTransformation;
61    private $fileTransformation;
62
63    /**
64     * constructor
65     */
66    public function __construct()
67    {
68        $this->dateTransformation = [$this, 'dummyTransformation'];
69        $this->fileTransformation = [$this, 'dummyTransformation'];
70    }
71
72    /**
73     * Get all available methods with remote access.
74     *
75     * @return array with information to all available methods
76     * @throws RemoteException
77     */
78    public function getMethods()
79    {
80        return array_merge($this->getCoreMethods(), $this->getPluginMethods());
81    }
82
83    /**
84     * Call a method via remote api.
85     *
86     * @param string $method name of the method to call.
87     * @param array $args arguments to pass to the given method
88     * @return mixed result of method call, must be a primitive type.
89     * @throws RemoteException
90     */
91    public function call($method, $args = [])
92    {
93        if ($args === null) {
94            $args = [];
95        }
96        // Ensure we have at least one '.' in $method
97        [$type, $pluginName, /* call */] = sexplode('.', $method . '.', 3, '');
98        if ($type === 'plugin') {
99            return $this->callPlugin($pluginName, $method, $args);
100        }
101        if ($this->coreMethodExist($method)) {
102            return $this->callCoreMethod($method, $args);
103        }
104        return $this->callCustomCallPlugin($method, $args);
105    }
106
107    /**
108     * Check existance of core methods
109     *
110     * @param string $name name of the method
111     * @return bool if method exists
112     */
113    private function coreMethodExist($name)
114    {
115        $coreMethods = $this->getCoreMethods();
116        return array_key_exists($name, $coreMethods);
117    }
118
119    /**
120     * Try to call custom methods provided by plugins
121     *
122     * @param string $method name of method
123     * @param array $args
124     * @return mixed
125     * @throws RemoteException if method not exists
126     */
127    private function callCustomCallPlugin($method, $args)
128    {
129        $customCalls = $this->getCustomCallPlugins();
130        if (!array_key_exists($method, $customCalls)) {
131            throw new RemoteException('Method does not exist', -32603);
132        }
133        [$plugin, $method] = $customCalls[$method];
134        $fullMethod = "plugin.$plugin.$method";
135        return $this->callPlugin($plugin, $fullMethod, $args);
136    }
137
138    /**
139     * Returns plugin calls that are registered via RPC_CALL_ADD action
140     *
141     * @return array with pairs of custom plugin calls
142     * @triggers RPC_CALL_ADD
143     */
144    private function getCustomCallPlugins()
145    {
146        if ($this->pluginCustomCalls === null) {
147            $data = [];
148            Event::createAndTrigger('RPC_CALL_ADD', $data);
149            $this->pluginCustomCalls = $data;
150        }
151        return $this->pluginCustomCalls;
152    }
153
154    /**
155     * Call a plugin method
156     *
157     * @param string $pluginName
158     * @param string $method method name
159     * @param array $args
160     * @return mixed return of custom method
161     * @throws RemoteException
162     */
163    private function callPlugin($pluginName, $method, $args)
164    {
165        $plugin = plugin_load('remote', $pluginName);
166        $methods = $this->getPluginMethods();
167        if (!$plugin instanceof PluginInterface) {
168            throw new RemoteException('Method does not exist', -32603);
169        }
170        $this->checkAccess($methods[$method]);
171        $name = $this->getMethodName($methods, $method);
172        try {
173            set_error_handler([$this, "argumentWarningHandler"], E_WARNING); // for PHP <7.1
174            return call_user_func_array([$plugin, $name], $args);
175        } catch (\ArgumentCountError $th) {
176            throw new RemoteException('Method does not exist - wrong parameter count.', -32603);
177        } finally {
178            restore_error_handler();
179        }
180    }
181
182    /**
183     * Call a core method
184     *
185     * @param string $method name of method
186     * @param array $args
187     * @return mixed
188     * @throws RemoteException if method not exist
189     */
190    private function callCoreMethod($method, $args)
191    {
192        $coreMethods = $this->getCoreMethods();
193        $this->checkAccess($coreMethods[$method]);
194        if (!isset($coreMethods[$method])) {
195            throw new RemoteException('Method does not exist', -32603);
196        }
197        $this->checkArgumentLength($coreMethods[$method], $args);
198        try {
199            set_error_handler([$this, "argumentWarningHandler"], E_WARNING); // for PHP <7.1
200            return call_user_func_array([$this->coreMethods, $this->getMethodName($coreMethods, $method)], $args);
201        } catch (\ArgumentCountError $th) {
202            throw new RemoteException('Method does not exist - wrong parameter count.', -32603);
203        } finally {
204            restore_error_handler();
205        }
206    }
207
208    /**
209     * Check if access should be checked
210     *
211     * @param array $methodMeta data about the method
212     * @throws AccessDeniedException
213     */
214    private function checkAccess($methodMeta)
215    {
216        if (!isset($methodMeta['public'])) {
217            $this->forceAccess();
218        } elseif ($methodMeta['public'] == '0') {
219            $this->forceAccess();
220        }
221    }
222
223    /**
224     * Check the number of parameters
225     *
226     * @param array $methodMeta data about the method
227     * @param array $args
228     * @throws RemoteException if wrong parameter count
229     */
230    private function checkArgumentLength($methodMeta, $args)
231    {
232        if (count($methodMeta['args']) < count($args)) {
233            throw new RemoteException('Method does not exist - wrong parameter count.', -32603);
234        }
235    }
236
237    /**
238     * Determine the name of the real method
239     *
240     * @param array $methodMeta list of data of the methods
241     * @param string $method name of method
242     * @return string
243     */
244    private function getMethodName($methodMeta, $method)
245    {
246        if (isset($methodMeta[$method]['name'])) {
247            return $methodMeta[$method]['name'];
248        }
249        $method = explode('.', $method);
250        return $method[count($method) - 1];
251    }
252
253    /**
254     * Perform access check for current user
255     *
256     * @return bool true if the current user has access to remote api.
257     * @throws AccessDeniedException If remote access disabled
258     */
259    public function hasAccess()
260    {
261        global $conf;
262        global $USERINFO;
263        /** @var Input $INPUT */
264        global $INPUT;
265
266        if (!$conf['remote']) {
267            throw new AccessDeniedException('server error. RPC server not enabled.', -32604);
268        }
269        if (trim($conf['remoteuser']) == '!!not set!!') {
270            return false;
271        }
272        if (!$conf['useacl']) {
273            return true;
274        }
275        if (trim($conf['remoteuser']) == '') {
276            return true;
277        }
278
279        return auth_isMember(
280            $conf['remoteuser'],
281            $INPUT->server->str('REMOTE_USER'),
282            (array)($USERINFO['grps'] ?? [])
283        );
284    }
285
286    /**
287     * Requests access
288     *
289     * @return void
290     * @throws AccessDeniedException On denied access.
291     */
292    public function forceAccess()
293    {
294        if (!$this->hasAccess()) {
295            throw new AccessDeniedException('server error. not authorized to call method', -32604);
296        }
297    }
298
299    /**
300     * Collects all the methods of the enabled Remote Plugins
301     *
302     * @return array all plugin methods.
303     * @throws RemoteException if not implemented
304     */
305    public function getPluginMethods()
306    {
307        if ($this->pluginMethods === null) {
308            $this->pluginMethods = [];
309            $plugins = plugin_list('remote');
310
311            foreach ($plugins as $pluginName) {
312                /** @var RemotePlugin $plugin */
313                $plugin = plugin_load('remote', $pluginName);
314                if (!is_subclass_of($plugin, 'dokuwiki\Extension\RemotePlugin')) {
315                    throw new RemoteException(
316                        "Plugin $pluginName does not implement dokuwiki\Extension\RemotePlugin"
317                    );
318                }
319
320                try {
321                    $methods = $plugin->_getMethods();
322                } catch (\ReflectionException $e) {
323                    throw new RemoteException('Automatic aggregation of available remote methods failed', 0, $e);
324                }
325
326                foreach ($methods as $method => $meta) {
327                    $this->pluginMethods["plugin.$pluginName.$method"] = $meta;
328                }
329            }
330        }
331        return $this->pluginMethods;
332    }
333
334    /**
335     * Collects all the core methods
336     *
337     * @param ApiCore|\RemoteAPICoreTest $apiCore this parameter is used for testing.
338     *        Here you can pass a non-default RemoteAPICore instance. (for mocking)
339     * @return array all core methods.
340     */
341    public function getCoreMethods($apiCore = null)
342    {
343        if ($this->coreMethods === null) {
344            if ($apiCore === null) {
345                $this->coreMethods = new ApiCore($this);
346            } else {
347                $this->coreMethods = $apiCore;
348            }
349        }
350        return $this->coreMethods->getRemoteInfo();
351    }
352
353    /**
354     * Transform file to xml
355     *
356     * @param mixed $data
357     * @return mixed
358     */
359    public function toFile($data)
360    {
361        return call_user_func($this->fileTransformation, $data);
362    }
363
364    /**
365     * Transform date to xml
366     *
367     * @param mixed $data
368     * @return mixed
369     */
370    public function toDate($data)
371    {
372        return call_user_func($this->dateTransformation, $data);
373    }
374
375    /**
376     * A simple transformation
377     *
378     * @param mixed $data
379     * @return mixed
380     */
381    public function dummyTransformation($data)
382    {
383        return $data;
384    }
385
386    /**
387     * Set the transformer function
388     *
389     * @param callback $dateTransformation
390     */
391    public function setDateTransformation($dateTransformation)
392    {
393        $this->dateTransformation = $dateTransformation;
394    }
395
396    /**
397     * Set the transformer function
398     *
399     * @param callback $fileTransformation
400     */
401    public function setFileTransformation($fileTransformation)
402    {
403        $this->fileTransformation = $fileTransformation;
404    }
405
406    /**
407     * The error handler that catches argument-related warnings
408     */
409    public function argumentWarningHandler($errno, $errstr)
410    {
411        if (str_starts_with($errstr, 'Missing argument ')) {
412            throw new RemoteException('Method does not exist - wrong parameter count.', -32603);
413        }
414    }
415}
416