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