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