1<?php 2 3namespace dokuwiki\Remote; 4 5use Doku_Renderer_xhtml; 6use dokuwiki\ChangeLog\PageChangeLog; 7use dokuwiki\Extension\AuthPlugin; 8use dokuwiki\Extension\Event; 9use dokuwiki\Remote\Response\Link; 10use dokuwiki\Remote\Response\Media; 11use dokuwiki\Remote\Response\MediaRevision; 12use dokuwiki\Remote\Response\Page; 13use dokuwiki\Remote\Response\PageHit; 14use dokuwiki\Remote\Response\PageRevision; 15use dokuwiki\Remote\Response\User; 16use dokuwiki\Utf8\Sort; 17 18/** 19 * Provides the core methods for the remote API. 20 * The methods are ordered in 'wiki.<method>' and 'dokuwiki.<method>' namespaces 21 */ 22class ApiCore 23{ 24 /** @var int Increased whenever the API is changed */ 25 public const API_VERSION = 12; 26 27 /** 28 * Returns details about the core methods 29 * 30 * @return array 31 */ 32 public function getMethods() 33 { 34 return [ 35 'core.getAPIVersion' => (new ApiCall([$this, 'getAPIVersion'], 'info'))->setPublic(), 36 37 'core.getWikiVersion' => new ApiCall('getVersion', 'info'), 38 'core.getWikiTitle' => (new ApiCall([$this, 'getWikiTitle'], 'info'))->setPublic(), 39 'core.getWikiTime' => (new ApiCall([$this, 'getWikiTime'], 'info')), 40 41 'core.login' => (new ApiCall([$this, 'login'], 'user'))->setPublic(), 42 'core.logoff' => new ApiCall([$this, 'logoff'], 'user'), 43 'core.whoAmI' => (new ApiCall([$this, 'whoAmI'], 'user')), 44 'core.aclCheck' => new ApiCall([$this, 'aclCheck'], 'user'), 45 46 'core.listPages' => new ApiCall([$this, 'listPages'], 'pages'), 47 'core.searchPages' => new ApiCall([$this, 'searchPages'], 'pages'), 48 'core.getRecentPageChanges' => new ApiCall([$this, 'getRecentPageChanges'], 'pages'), 49 50 'core.getPage' => (new ApiCall([$this, 'getPage'], 'pages')), 51 'core.getPageHTML' => (new ApiCall([$this, 'getPageHTML'], 'pages')), 52 'core.getPageInfo' => (new ApiCall([$this, 'getPageInfo'], 'pages')), 53 'core.getPageVersions' => new ApiCall([$this, 'getPageVersions'], 'pages'), 54 'core.getPageLinks' => new ApiCall([$this, 'getPageLinks'], 'pages'), 55 'core.getPageBackLinks' => new ApiCall([$this, 'getPageBackLinks'], 'pages'), 56 57 'core.lockPages' => new ApiCall([$this, 'lockPages'], 'pages'), 58 'core.unlockPages' => new ApiCall([$this, 'unlockPages'], 'pages'), 59 'core.savePage' => new ApiCall([$this, 'savePage'], 'pages'), 60 'core.appendPage' => new ApiCall([$this, 'appendPage'], 'pages'), 61 62 'core.listMedia' => new ApiCall([$this, 'listMedia'], 'media'), 63 // todo: implement searchMedia 64 'core.getRecentMediaChanges' => new ApiCall([$this, 'getRecentMediaChanges'], 'media'), 65 66 'core.getMedia' => new ApiCall([$this, 'getMedia'], 'media'), 67 'core.getMediaInfo' => new ApiCall([$this, 'getMediaInfo'], 'media'), 68 // todo: implement getMediaVersions 69 // todo: implement getMediaUsage 70 71 'core.saveMedia' => new ApiCall([$this, 'saveMedia'], 'media'), 72 'core.deleteMedia' => new ApiCall([$this, 'deleteMedia'], 'media'), 73 74 75 'core.createUser' => new ApiCall([$this, 'createUser'], 'user'), 76 'core.deleteUser' => new ApiCall([$this, 'deleteUser'], 'user'), 77 ]; 78 } 79 80 // region info 81 82 /** 83 * Return the API version 84 * 85 * This is the version of the DokuWiki API. It increases whenever the API definition changes. 86 * 87 * When developing a client, you should check this version and make sure you can handle it. 88 * 89 * @return int 90 */ 91 public function getAPIVersion() 92 { 93 return self::API_VERSION; 94 } 95 96 /** 97 * Returns the wiki title 98 * 99 * @link https://www.dokuwiki.org/config:title 100 * @return string 101 */ 102 public function getWikiTitle() 103 { 104 global $conf; 105 return $conf['title']; 106 } 107 108 /** 109 * Return the current server time 110 * 111 * Returns a Unix timestamp (seconds since 1970-01-01 00:00:00 UTC). 112 * 113 * You can use this to compensate for differences between your client's time and the 114 * server's time when working with last modified timestamps (revisions). 115 * 116 * @return int A unix timestamp 117 */ 118 public function getWikiTime() 119 { 120 return time(); 121 } 122 123 // endregion 124 125 // region user 126 127 /** 128 * Login 129 * 130 * This will use the given credentials and attempt to login the user. This will set the 131 * appropriate cookies, which can be used for subsequent requests. 132 * 133 * Use of this mechanism is discouraged. Using token authentication is preferred. 134 * 135 * @param string $user The user name 136 * @param string $pass The password 137 * @return int If the login was successful 138 */ 139 public function login($user, $pass) 140 { 141 global $conf; 142 /** @var AuthPlugin $auth */ 143 global $auth; 144 145 if (!$conf['useacl']) return 0; 146 if (!$auth instanceof AuthPlugin) return 0; 147 148 @session_start(); // reopen session for login 149 $ok = null; 150 if ($auth->canDo('external')) { 151 $ok = $auth->trustExternal($user, $pass, false); 152 } 153 if ($ok === null) { 154 $evdata = [ 155 'user' => $user, 156 'password' => $pass, 157 'sticky' => false, 158 'silent' => true 159 ]; 160 $ok = Event::createAndTrigger('AUTH_LOGIN_CHECK', $evdata, 'auth_login_wrapper'); 161 } 162 session_write_close(); // we're done with the session 163 164 return $ok; 165 } 166 167 /** 168 * Log off 169 * 170 * Attempt to log out the current user, deleting the appropriate cookies 171 * 172 * Use of this mechanism is discouraged. Using token authentication is preferred. 173 * 174 * @return int 0 on failure, 1 on success 175 */ 176 public function logoff() 177 { 178 global $conf; 179 global $auth; 180 if (!$conf['useacl']) return 0; 181 if (!$auth instanceof AuthPlugin) return 0; 182 183 auth_logoff(); 184 185 return 1; 186 } 187 188 /** 189 * Info about the currently authenticated user 190 * 191 * @return User 192 */ 193 public function whoAmI() 194 { 195 return new User([]); 196 } 197 198 /** 199 * Check ACL Permissions 200 * 201 * This call allows to check the permissions for a given page/media and user/group combination. 202 * If no user/group is given, the current user is used. 203 * 204 * Read the link below to learn more about the permission levels. 205 * 206 * @link https://www.dokuwiki.org/acl#background_info 207 * @param string $page A page or media ID 208 * @param string $user username 209 * @param string[] $groups array of groups 210 * @return int permission level 211 */ 212 public function aclCheck($page, $user = '', $groups = []) 213 { 214 /** @var AuthPlugin $auth */ 215 global $auth; 216 217 $page = $this->resolvePageId($page); 218 if ($user === '') { 219 return auth_quickaclcheck($page); 220 } else { 221 if ($groups === []) { 222 $userinfo = $auth->getUserData($user); 223 if ($userinfo === false) { 224 $groups = []; 225 } else { 226 $groups = $userinfo['grps']; 227 } 228 } 229 return auth_aclcheck($page, $user, $groups); 230 } 231 } 232 233 // endregion 234 235 // region pages 236 237 /** 238 * List all pages in the given namespace (and below) 239 * 240 * Setting the `depth` to `0` and the `namespace` to `""` will return all pages in the wiki. 241 * 242 * @param string $namespace The namespace to search. Empty string for root namespace 243 * @param int $depth How deep to search. 0 for all subnamespaces 244 * @param bool $hash Whether to include a MD5 hash of the page content 245 * @return Page[] A list of matching pages 246 */ 247 public function listPages($namespace = '', $depth = 1, $hash = false) 248 { 249 global $conf; 250 251 $namespace = cleanID($namespace); 252 253 // shortcut for all pages 254 if ($namespace === '' && $depth === 0) { 255 return $this->getAllPages($hash); 256 } 257 258 // run our search iterator to get the pages 259 $dir = utf8_encodeFN(str_replace(':', '/', $namespace)); 260 $data = []; 261 $opts['skipacl'] = 0; 262 $opts['depth'] = $depth; // FIXME depth needs to be calculated relative to $dir 263 $opts['hash'] = $hash; 264 search($data, $conf['datadir'], 'search_allpages', $opts, $dir); 265 266 return array_map(fn($item) => new Page($item), $data); 267 } 268 269 /** 270 * Get all pages at once 271 * 272 * This is uses the page index and is quicker than iterating which is done in listPages() 273 * 274 * @return Page[] A list of all pages 275 * @see listPages() 276 */ 277 protected function getAllPages($hash = false) 278 { 279 $list = []; 280 $pages = idx_get_indexer()->getPages(); 281 Sort::ksort($pages); 282 283 foreach (array_keys($pages) as $idx) { 284 $perm = auth_quickaclcheck($pages[$idx]); 285 if ($perm < AUTH_READ || isHiddenPage($pages[$idx]) || !page_exists($pages[$idx])) { 286 continue; 287 } 288 289 $page = new Page([ 290 'id' => $pages[$idx], 291 'perm' => $perm, 292 ]); 293 if ($hash) $page->calculateHash(); 294 295 $list[] = $page; 296 } 297 298 return $list; 299 } 300 301 /** 302 * Do a fulltext search 303 * 304 * This executes a full text search and returns the results. The query uses the standard 305 * DokuWiki search syntax. 306 * 307 * Snippets are provided for the first 15 results only. The title is either the first heading 308 * or the page id depending on the wiki's configuration. 309 * 310 * @link https://www.dokuwiki.org/search#syntax 311 * @param string $query The search query as supported by the DokuWiki search 312 * @return PageHit[] A list of matching pages 313 */ 314 public function searchPages($query) 315 { 316 $regex = []; 317 $data = ft_pageSearch($query, $regex); 318 $pages = []; 319 320 // prepare additional data 321 $idx = 0; 322 foreach ($data as $id => $score) { 323 $file = wikiFN($id); 324 325 if ($idx < FT_SNIPPET_NUMBER) { 326 $snippet = ft_snippet($id, $regex); 327 $idx++; 328 } else { 329 $snippet = ''; 330 } 331 332 $pages[] = new PageHit([ 333 'id' => $id, 334 'score' => (int)$score, 335 'rev' => filemtime($file), 336 'mtime' => filemtime($file), 337 'size' => filesize($file), 338 'snippet' => $snippet, 339 'title' => useHeading('navigation') ? p_get_first_heading($id) : $id 340 ]); 341 } 342 return $pages; 343 } 344 345 /** 346 * Get recent page changes 347 * 348 * Returns a list of recent changes to wiki pages. The results can be limited to changes newer than 349 * a given timestamp. 350 * 351 * Only changes within the configured `$conf['recent']` range are returned. This is the default 352 * when no timestamp is given. 353 * 354 * @link https://www.dokuwiki.org/config:recent 355 * @param int $timestamp Only show changes newer than this unix timestamp 356 * @return PageRevision[] 357 * @author Michael Klier <chi@chimeric.de> 358 * @author Michael Hamann <michael@content-space.de> 359 */ 360 public function getRecentPageChanges($timestamp = 0) 361 { 362 $recents = getRecentsSince($timestamp); 363 364 $changes = []; 365 foreach ($recents as $recent) { 366 $changes[] = new PageRevision([ 367 'id' => $recent['id'], 368 'revision' => $recent['date'], 369 'author' => $recent['user'], 370 'ip' => $recent['ip'], 371 'summary' => $recent['sum'], 372 'type' => $recent['type'], 373 'sizechange' => $recent['sizechange'], 374 ]); 375 } 376 377 return $changes; 378 } 379 380 /** 381 * Get a wiki page's syntax 382 * 383 * Returns the syntax of the given page. When no revision is given, the current revision is returned. 384 * 385 * A non-existing page (or revision) will return an empty string usually. For the current revision 386 * a page template will be returned if configured. 387 * 388 * Read access is required for the page. 389 * 390 * @param string $page wiki page id 391 * @param int $rev Revision timestamp to access an older revision 392 * @return string the syntax of the page 393 * @throws AccessDeniedException if no permission for page 394 */ 395 public function getPage($page, $rev = '') 396 { 397 $page = $this->resolvePageId($page); 398 if (auth_quickaclcheck($page) < AUTH_READ) { 399 throw new AccessDeniedException('You are not allowed to read this file', 111); 400 } 401 $text = rawWiki($page, $rev); 402 if (!$text && !$rev) { 403 return pageTemplate($page); 404 } else { 405 return $text; 406 } 407 } 408 409 /** 410 * Return a wiki page rendered to HTML 411 * 412 * The page is rendered to HTML as it would be in the wiki. The HTML consist only of the data for the page 413 * content itself, no surrounding structural tags, header, footers, sidebars etc are returned. 414 * 415 * References in the HTML are relative to the wiki base URL unless the `canonical` configuration is set. 416 * 417 * If the page does not exist, an empty string is returned. 418 * 419 * @link https://www.dokuwiki.org/config:canonical 420 * @param string $page page id 421 * @param int $rev revision timestamp 422 * @return string Rendered HTML for the page 423 * @throws AccessDeniedException no access to page 424 */ 425 public function getPageHTML($page, $rev = '') 426 { 427 $page = $this->resolvePageId($page); 428 if (auth_quickaclcheck($page) < AUTH_READ) { 429 throw new AccessDeniedException('You are not allowed to read this page', 111); 430 } 431 return (string)p_wiki_xhtml($page, $rev, false); 432 } 433 434 /** 435 * Return some basic data about a page 436 * 437 * The call will return an error if the requested page does not exist. 438 * 439 * Read access is required for the page. 440 * 441 * @param string $page page id 442 * @param int $rev revision timestamp 443 * @param bool $author whether to include the author information 444 * @param bool $hash whether to include the MD5 hash of the page content 445 * @return Page 446 * @throws AccessDeniedException no access for page 447 * @throws RemoteException page not exist 448 */ 449 public function getPageInfo($page, $rev = '', $author = false, $hash = false) 450 { 451 $page = $this->resolvePageId($page); 452 if (auth_quickaclcheck($page) < AUTH_READ) { 453 throw new AccessDeniedException('You are not allowed to read this page', 111); 454 } 455 if (!page_exists($page)) { 456 throw new RemoteException('The requested page does not exist', 121); 457 } 458 459 $result = new Page(['id' => $page, 'rev' => $rev]); 460 if ($author) $result->retrieveAuthor(); 461 if ($hash) $result->calculateHash(); 462 463 return $result; 464 } 465 466 /** 467 * Returns a list of available revisions of a given wiki page 468 * 469 * The number of returned pages is set by `$conf['recent']`, but non accessible revisions pages 470 * are skipped, so less than that may be returned. 471 * 472 * @link https://www.dokuwiki.org/config:recent 473 * @param string $page page id 474 * @param int $first skip the first n changelog lines, 0 starts at the current revision 475 * @return PageRevision[] 476 * @throws AccessDeniedException no read access for page 477 * @throws RemoteException empty id 478 * @author Michael Klier <chi@chimeric.de> 479 */ 480 public function getPageVersions($page, $first = 0) 481 { 482 $page = $this->resolvePageId($page); 483 if (auth_quickaclcheck($page) < AUTH_READ) { 484 throw new AccessDeniedException('You are not allowed to read this page', 111); 485 } 486 global $conf; 487 488 if (empty($page)) { 489 throw new RemoteException('Empty page ID', 131); 490 } 491 492 $pagelog = new PageChangeLog($page); 493 $pagelog->setChunkSize(1024); 494 // old revisions are counted from 0, so we need to subtract 1 for the current one 495 $revisions = $pagelog->getRevisions($first - 1, $conf['recent']); 496 497 $result = []; 498 foreach ($revisions as $rev) { 499 if (!page_exists($page, $rev)) continue; // skip non-existing revisions 500 $info = $pagelog->getRevisionInfo($rev); 501 502 $result[] = new PageRevision([ 503 'id' => $page, 504 'revision' => $rev, 505 'author' => $info['user'], 506 'ip' => $info['ip'], 507 'summary' => $info['sum'], 508 'type' => $info['type'], 509 'sizechange' => $info['sizechange'], 510 ]); 511 } 512 513 return $result; 514 } 515 516 /** 517 * Get a page's links 518 * 519 * This returns a list of links found in the given page. This includes internal, external and interwiki links 520 * 521 * Read access for the given page is needed 522 * 523 * @param string $page page id 524 * @return Link[] A list of links found on the given page 525 * @throws AccessDeniedException no read access for page 526 * @author Michael Klier <chi@chimeric.de> 527 * @todo returning link titles would be a nice addition 528 * @todo hash handling seems not to be correct 529 */ 530 public function getPageLinks($page) 531 { 532 $page = $this->resolvePageId($page); 533 if (auth_quickaclcheck($page) < AUTH_READ) { 534 throw new AccessDeniedException('You are not allowed to read this page', 111); 535 } 536 537 // resolve page instructions 538 $ins = p_cached_instructions(wikiFN($page)); 539 540 // instantiate new Renderer - needed for interwiki links 541 $Renderer = new Doku_Renderer_xhtml(); 542 $Renderer->interwiki = getInterwiki(); 543 544 // parse instructions 545 $links = []; 546 foreach ($ins as $in) { 547 switch ($in[0]) { 548 case 'internallink': 549 $links[] = new Link([ 550 'type' => 'local', 551 'page' => $in[1][0], 552 'href' => wl($in[1][0]), 553 ]); 554 break; 555 case 'externallink': 556 $links[] = new Link([ 557 'type' => 'extern', 558 'page' => $in[1][0], 559 'href' => $in[1][0], 560 ]); 561 break; 562 case 'interwikilink': 563 $url = $Renderer->_resolveInterWiki($in[1][2], $in[1][3]); 564 $links[] = new Link([ 565 'type' => 'interwiki', 566 'page' => $in[1][0], 567 'href' => $url, 568 ]); 569 break; 570 } 571 } 572 573 return ($links); 574 } 575 576 /** 577 * Get a page's backlinks 578 * 579 * A backlink is a wiki link on another page that links to the given page. 580 * 581 * Only links from pages readable by the current user are returned. 582 * 583 * @param string $page page id 584 * @return string[] A list of pages linking to the given page 585 */ 586 public function getPageBackLinks($page) 587 { 588 return ft_backlinks($this->resolvePageId($page)); 589 } 590 591 /** 592 * Lock the given set of pages 593 * 594 * This call will try to lock all given pages. It will return a list of pages that were 595 * successfully locked. If a page could not be locked, eg. because a different user is 596 * currently holding a lock, that page will be missing from the returned list. 597 * 598 * You should always ensure that the list of returned pages matches the given list of 599 * pages. It's up to you to decide how to handle failed locking. 600 * 601 * Note: you can only lock pages that you have write access for. It is possible to create 602 * a lock for a page that does not exist, yet. 603 * 604 * Note: it is not necessary to lock a page before saving it. The `savePage()` call will 605 * automatically lock and unlock the page for you. However if you plan to do related 606 * operations on multiple pages, locking them all at once beforehand can be useful. 607 * 608 * @param string[] $pages A list of pages to lock 609 * @return string[] A list of pages that were successfully locked 610 */ 611 public function lockPages($pages) 612 { 613 $locked = []; 614 615 foreach ($pages as $id) { 616 $id = $this->resolvePageId($id); 617 if ($id === '') continue; 618 if (auth_quickaclcheck($id) < AUTH_EDIT || checklock($id)) { 619 continue; 620 } 621 lock($id); 622 $locked[] = $id; 623 } 624 return $locked; 625 } 626 627 /** 628 * Unlock the given set of pages 629 * 630 * This call will try to unlock all given pages. It will return a list of pages that were 631 * successfully unlocked. If a page could not be unlocked, eg. because a different user is 632 * currently holding a lock, that page will be missing from the returned list. 633 * 634 * You should always ensure that the list of returned pages matches the given list of 635 * pages. It's up to you to decide how to handle failed unlocking. 636 * 637 * Note: you can only unlock pages that you have write access for. 638 * 639 * @param string[] $pages A list of pages to unlock 640 * @return string[] A list of pages that were successfully unlocked 641 */ 642 public function unlockPages($pages) 643 { 644 $unlocked = []; 645 646 foreach ($pages as $id) { 647 $id = $this->resolvePageId($id); 648 if ($id === '') continue; 649 if (auth_quickaclcheck($id) < AUTH_EDIT || !unlock($id)) { 650 continue; 651 } 652 $unlocked[] = $id; 653 } 654 655 return $unlocked; 656 } 657 658 /** 659 * Save a wiki page 660 * 661 * Saves the given wiki text to the given page. If the page does not exist, it will be created. 662 * Just like in the wiki, saving an empty text will delete the page. 663 * 664 * You need write permissions for the given page and the page may not be locked by another user. 665 * 666 * @param string $page page id 667 * @param string $text wiki text 668 * @param string $summary edit summary 669 * @param bool $isminor whether this is a minor edit 670 * @return bool Returns true on success 671 * @throws AccessDeniedException no write access for page 672 * @throws RemoteException no id, empty new page or locked 673 * @author Michael Klier <chi@chimeric.de> 674 */ 675 public function savePage($page, $text, $summary = '', $isminor = false) 676 { 677 global $TEXT; 678 global $lang; 679 680 $page = $this->resolvePageId($page); 681 $TEXT = cleanText($text); 682 683 if (empty($page)) { 684 throw new RemoteException('Empty page ID', 131); 685 } 686 687 if (!page_exists($page) && trim($TEXT) == '') { 688 throw new RemoteException('Refusing to write an empty new wiki page', 132); 689 } 690 691 if (auth_quickaclcheck($page) < AUTH_EDIT) { 692 throw new AccessDeniedException('You are not allowed to edit this page', 112); 693 } 694 695 // Check, if page is locked 696 if (checklock($page)) { 697 throw new RemoteException('The page is currently locked', 133); 698 } 699 700 // SPAM check 701 if (checkwordblock()) { 702 throw new RemoteException('Positive wordblock check', 134); 703 } 704 705 // autoset summary on new pages 706 if (!page_exists($page) && empty($summary)) { 707 $summary = $lang['created']; 708 } 709 710 // autoset summary on deleted pages 711 if (page_exists($page) && empty($TEXT) && empty($summary)) { 712 $summary = $lang['deleted']; 713 } 714 715 lock($page); 716 saveWikiText($page, $TEXT, $summary, $isminor); 717 unlock($page); 718 719 // run the indexer if page wasn't indexed yet 720 idx_addPage($page); 721 722 return true; 723 } 724 725 /** 726 * Appends text to the end of a wiki page 727 * 728 * If the page does not exist, it will be created. If a page template for the non-existant 729 * page is configured, the given text will appended to that template. 730 * 731 * The call will create a new page revision. 732 * 733 * You need write permissions for the given page. 734 * 735 * @param string $page page id 736 * @param string $text wiki text 737 * @param string $summary edit summary 738 * @param bool $isminor whether this is a minor edit 739 * @return bool Returns true on success 740 * @throws AccessDeniedException 741 * @throws RemoteException 742 */ 743 public function appendPage($page, $text, $summary, $isminor) 744 { 745 $currentpage = $this->getPage($page); 746 if (!is_string($currentpage)) { 747 $currentpage = ''; 748 } 749 return $this->savePage($page, $currentpage . $text, $summary, $isminor); 750 } 751 752 // endregion 753 754 // region media 755 756 /** 757 * List all media files in the given namespace (and below) 758 * 759 * Setting the `depth` to `0` and the `namespace` to `""` will return all media files in the wiki. 760 * 761 * When `pattern` is given, it needs to be a valid regular expression as understood by PHP's 762 * `preg_match()` including delimiters. 763 * The pattern is matched against the full media ID, including the namespace. 764 * 765 * @link https://www.php.net/manual/en/reference.pcre.pattern.syntax.php 766 * @param string $namespace The namespace to search. Empty string for root namespace 767 * @param string $pattern A regular expression to filter the returned files 768 * @param int $depth How deep to search. 0 for all subnamespaces 769 * @param bool $hash Whether to include a MD5 hash of the media content 770 * @return Media[] 771 * @throws AccessDeniedException no access to the media files 772 * @author Gina Haeussge <osd@foosel.net> 773 */ 774 public function listMedia($namespace = '', $pattern = '', $depth = 1, $hash = false) 775 { 776 global $conf; 777 778 $namespace = cleanID($namespace); 779 780 if (auth_quickaclcheck($namespace . ':*') < AUTH_READ) { 781 throw new AccessDeniedException('You are not allowed to list media files.', 215); 782 } 783 784 $options = [ 785 'skipacl' => 0, 786 'depth' => $depth, 787 'hash' => $hash, 788 'pattern' => $pattern, 789 ]; 790 791 $dir = utf8_encodeFN(str_replace(':', '/', $namespace)); 792 $data = []; 793 search($data, $conf['mediadir'], 'search_media', $options, $dir); 794 return array_map(fn($item) => new Media($item), $data); 795 } 796 797 /** 798 * Get recent media changes 799 * 800 * Returns a list of recent changes to media files. The results can be limited to changes newer than 801 * a given timestamp. 802 * 803 * Only changes within the configured `$conf['recent']` range are returned. This is the default 804 * when no timestamp is given. 805 * 806 * @link https://www.dokuwiki.org/config:recent 807 * @param int $timestamp Only show changes newer than this unix timestamp 808 * @return MediaRevision[] 809 * @author Michael Klier <chi@chimeric.de> 810 * @author Michael Hamann <michael@content-space.de> 811 */ 812 public function getRecentMediaChanges($timestamp = 0) 813 { 814 815 $recents = getRecentsSince($timestamp, null, '', RECENTS_MEDIA_CHANGES); 816 817 $changes = []; 818 foreach ($recents as $recent) { 819 $changes[] = new MediaRevision([ 820 'id' => $recent['id'], 821 'revision' => $recent['date'], 822 'author' => $recent['user'], 823 'ip' => $recent['ip'], 824 'summary' => $recent['sum'], 825 'type' => $recent['type'], 826 'sizechange' => $recent['sizechange'], 827 ]); 828 } 829 830 return $changes; 831 } 832 833 /** 834 * Get a media file's content 835 * 836 * Returns the content of the given media file. When no revision is given, the current revision is returned. 837 * 838 * @link https://en.wikipedia.org/wiki/Base64 839 * @param string $media file id 840 * @param int $rev revision timestamp 841 * @return string Base64 encoded media file contents 842 * @throws AccessDeniedException no permission for media 843 * @throws RemoteException not exist 844 * @author Gina Haeussge <osd@foosel.net> 845 * 846 */ 847 public function getMedia($media, $rev = '') 848 { 849 $media = cleanID($media); 850 if (auth_quickaclcheck($media) < AUTH_READ) { 851 throw new AccessDeniedException('You are not allowed to read this file', 211); 852 } 853 854 $file = mediaFN($media, $rev); 855 if (!@ file_exists($file)) { 856 throw new RemoteException('The requested file does not exist', 221); 857 } 858 859 $data = io_readFile($file, false); 860 return base64_encode($data); 861 } 862 863 /** 864 * Return info about a media file 865 * 866 * The call will return an error if the requested media file does not exist. 867 * 868 * Read access is required for the media file. 869 * 870 * @param string $media file id 871 * @param int $rev revision timestamp 872 * @param bool $hash whether to include the MD5 hash of the media content 873 * @return Media 874 * @throws AccessDeniedException no permission for media 875 * @throws RemoteException if not exist 876 * @author Gina Haeussge <osd@foosel.net> 877 */ 878 public function getMediaInfo($media, $rev = '', $hash = false) 879 { 880 $media = cleanID($media); 881 if (auth_quickaclcheck($media) < AUTH_READ) { 882 throw new AccessDeniedException('You are not allowed to read this file', 211); 883 } 884 if (!media_exists($media, $rev)) { 885 throw new RemoteException('The requested media file does not exist', 221); 886 } 887 888 $file = mediaFN($media, $rev); 889 890 $info = new Media([ 891 'id' => $media, 892 'mtime' => filemtime($file), 893 'size' => filesize($file), 894 ]); 895 if ($hash) $info->calculateHash(); 896 897 return $info; 898 } 899 900 /** 901 * Uploads a file to the wiki 902 * 903 * The file data has to be passed as a base64 encoded string. 904 * 905 * @link https://en.wikipedia.org/wiki/Base64 906 * @param string $media media id 907 * @param string $base64 Base64 encoded file contents 908 * @param bool $overwrite Should an existing file be overwritten? 909 * @return bool Should always be true 910 * @throws RemoteException 911 * @author Michael Klier <chi@chimeric.de> 912 */ 913 public function saveMedia($media, $base64, $overwrite = false) 914 { 915 $media = cleanID($media); 916 $auth = auth_quickaclcheck(getNS($media) . ':*'); 917 918 if ($media === '') { 919 throw new RemoteException('Media ID not given.', 231); 920 } 921 922 // clean up base64 encoded data 923 $base64 = strtr($base64, [ 924 "\n" => '', // strip newlines 925 "\r" => '', // strip carriage returns 926 '-' => '+', // RFC4648 base64url 927 '_' => '/', // RFC4648 base64url 928 ' ' => '+', // JavaScript data uri 929 ]); 930 931 $data = base64_decode($base64, true); 932 if ($data === false) { 933 throw new RemoteException('Invalid base64 encoded data.', 231); // FIXME adjust code 934 } 935 936 // save temporary file 937 global $conf; 938 $ftmp = $conf['tmpdir'] . '/' . md5($media . clientIP()); 939 @unlink($ftmp); 940 io_saveFile($ftmp, $data); 941 942 $res = media_save(['name' => $ftmp], $media, $overwrite, $auth, 'rename'); 943 if (is_array($res)) { 944 throw new RemoteException($res[0], -$res[1]); // FIXME adjust code -1 * -1 = 1, we want a 23x code 945 } 946 return (bool)$res; // should always be true at this point 947 } 948 949 /** 950 * Deletes a file from the wiki 951 * 952 * You need to have delete permissions for the file. 953 * 954 * @param string $media media id 955 * @return bool Should always be true 956 * @throws AccessDeniedException no permissions 957 * @throws RemoteException file in use or not deleted 958 * @author Gina Haeussge <osd@foosel.net> 959 * 960 */ 961 public function deleteMedia($media) 962 { 963 $media = cleanID($media); 964 $auth = auth_quickaclcheck($media); 965 $res = media_delete($media, $auth); 966 if ($res & DOKU_MEDIA_DELETED) { 967 return true; 968 } elseif ($res & DOKU_MEDIA_NOT_AUTH) { 969 throw new AccessDeniedException('You don\'t have permissions to delete files.', 212); 970 } elseif ($res & DOKU_MEDIA_INUSE) { 971 throw new RemoteException('File is still referenced', 232); 972 } else { 973 throw new RemoteException('Could not delete file', 233); 974 } 975 } 976 977 // endregion 978 979 980 /** 981 * Create a new user 982 * 983 * If no password is provided, a password is auto generated. If the user can't be created 984 * by the auth backend a return value of `false` is returned. You need to check this return 985 * value rather than relying on the error code only. 986 * 987 * Superuser permission are required to create users. 988 * 989 * @param string $user The user's login name 990 * @param string $name The user's full name 991 * @param string $mail The user's email address 992 * @param string[] $groups The groups the user should be in 993 * @param string $password The user's password, empty for autogeneration 994 * @param bool $notify Whether to send a notification email to the user 995 * @return bool Wether the user was successfully created 996 * @throws AccessDeniedException 997 * @throws RemoteException 998 * @todo move to user manager plugin 999 * @todo handle error messages from auth backend 1000 */ 1001 public function createUser($user, $name, $mail, $groups, $password = '', $notify = false) 1002 { 1003 if (!auth_isadmin()) { 1004 throw new AccessDeniedException('Only admins are allowed to create users', 114); 1005 } 1006 1007 /** @var AuthPlugin $auth */ 1008 global $auth; 1009 1010 if (!$auth->canDo('addUser')) { 1011 throw new AccessDeniedException( 1012 sprintf('Authentication backend %s can\'t do addUser', $auth->getPluginName()), 1013 114 1014 ); 1015 } 1016 1017 $user = trim($auth->cleanUser($user)); 1018 $name = trim(preg_replace('/[\x00-\x1f:<>&%,;]+/', '', $name)); 1019 $mail = trim(preg_replace('/[\x00-\x1f:<>&%,;]+/', '', $mail)); 1020 1021 if ($user === '') throw new RemoteException('empty or invalid user', 401); 1022 if ($name === '') throw new RemoteException('empty or invalid user name', 402); 1023 if (!mail_isvalid($mail)) throw new RemoteException('empty or invalid mail address', 403); 1024 1025 if ((string)$password === '') { 1026 try { 1027 $password = auth_pwgen($user); 1028 } catch (\Exception $e) { 1029 throw new RemoteException('Could not generate password', 404); // FIXME adjust code 1030 } 1031 } 1032 1033 if (!is_array($groups) || $groups === []) { 1034 $groups = null; 1035 } 1036 1037 $ok = (bool)$auth->triggerUserMod('create', [$user, $password, $name, $mail, $groups]); 1038 1039 if ($ok && $notify) { 1040 auth_sendPassword($user, $password); 1041 } 1042 1043 return $ok; 1044 } 1045 1046 1047 /** 1048 * Remove a user 1049 * 1050 * You need to be a superuser to delete users. 1051 * 1052 * @param string[] $user The login name of the user to delete 1053 * @return bool wether the user was successfully deleted 1054 * @throws AccessDeniedException 1055 * @todo move to user manager plugin 1056 * @todo handle error messages from auth backend 1057 */ 1058 public function deleteUser($user) 1059 { 1060 if (!auth_isadmin()) { 1061 throw new AccessDeniedException('Only admins are allowed to delete users', 114); 1062 } 1063 /** @var AuthPlugin $auth */ 1064 global $auth; 1065 return (bool)$auth->triggerUserMod('delete', [[$user]]); 1066 } 1067 1068 1069 /** 1070 * Resolve page id 1071 * 1072 * @param string $id page id 1073 * @return string 1074 */ 1075 private function resolvePageId($id) 1076 { 1077 $id = cleanID($id); 1078 if (empty($id)) { 1079 global $conf; 1080 $id = cleanID($conf['start']); // FIXME I dont think we want that! 1081 } 1082 return $id; 1083 } 1084} 1085