1<?php 2 3namespace dokuwiki\Remote; 4 5use Doku_Renderer_xhtml; 6use dokuwiki\ChangeLog\PageChangeLog; 7use dokuwiki\ChangeLog\MediaChangeLog; 8use dokuwiki\Extension\AuthPlugin; 9use dokuwiki\Extension\Event; 10use dokuwiki\Remote\Response\Link; 11use dokuwiki\Remote\Response\Media; 12use dokuwiki\Remote\Response\MediaChange; 13use dokuwiki\Remote\Response\Page; 14use dokuwiki\Remote\Response\PageChange; 15use dokuwiki\Remote\Response\PageHit; 16use dokuwiki\Remote\Response\User; 17use dokuwiki\Search\Indexer; 18use dokuwiki\Search\FulltextSearch; 19use dokuwiki\Search\MetadataSearch; 20use dokuwiki\Utf8\PhpString; 21use dokuwiki\Utf8\Sort; 22 23/** 24 * Provides the core methods for the remote API. 25 * The methods are ordered in 'wiki.<method>' and 'dokuwiki.<method>' namespaces 26 */ 27class ApiCore 28{ 29 /** @var int Increased whenever the API is changed */ 30 public const API_VERSION = 14; 31 32 /** 33 * Returns details about the core methods 34 * 35 * @return array 36 */ 37 public function getMethods() 38 { 39 return [ 40 'core.getAPIVersion' => (new ApiCall($this->getAPIVersion(...), 'info'))->setPublic(), 41 42 'core.getWikiVersion' => new ApiCall('getVersion', 'info'), 43 'core.getWikiTitle' => (new ApiCall($this->getWikiTitle(...), 'info'))->setPublic(), 44 'core.getWikiTime' => (new ApiCall($this->getWikiTime(...), 'info')), 45 46 'core.login' => (new ApiCall($this->login(...), 'user'))->setPublic(), 47 'core.logoff' => new ApiCall($this->logoff(...), 'user'), 48 'core.whoAmI' => (new ApiCall($this->whoAmI(...), 'user')), 49 'core.aclCheck' => new ApiCall($this->aclCheck(...), 'user'), 50 51 'core.listPages' => new ApiCall($this->listPages(...), 'pages'), 52 'core.searchPages' => new ApiCall($this->searchPages(...), 'pages'), 53 'core.getRecentPageChanges' => new ApiCall($this->getRecentPageChanges(...), 'pages'), 54 55 'core.getPage' => (new ApiCall($this->getPage(...), 'pages')), 56 'core.getPageHTML' => (new ApiCall($this->getPageHTML(...), 'pages')), 57 'core.getPageInfo' => (new ApiCall($this->getPageInfo(...), 'pages')), 58 'core.getPageHistory' => new ApiCall($this->getPageHistory(...), 'pages'), 59 'core.getPageLinks' => new ApiCall($this->getPageLinks(...), 'pages'), 60 'core.getPageBackLinks' => new ApiCall($this->getPageBackLinks(...), 'pages'), 61 62 'core.lockPages' => new ApiCall($this->lockPages(...), 'pages'), 63 'core.unlockPages' => new ApiCall($this->unlockPages(...), 'pages'), 64 'core.savePage' => new ApiCall($this->savePage(...), 'pages'), 65 'core.appendPage' => new ApiCall($this->appendPage(...), 'pages'), 66 67 'core.listMedia' => new ApiCall($this->listMedia(...), 'media'), 68 'core.getRecentMediaChanges' => new ApiCall($this->getRecentMediaChanges(...), 'media'), 69 70 'core.getMedia' => new ApiCall($this->getMedia(...), 'media'), 71 'core.getMediaInfo' => new ApiCall($this->getMediaInfo(...), 'media'), 72 'core.getMediaUsage' => new ApiCall($this->getMediaUsage(...), 'media'), 73 'core.getMediaHistory' => new ApiCall($this->getMediaHistory(...), 'media'), 74 75 'core.saveMedia' => new ApiCall($this->saveMedia(...), 'media'), 76 'core.deleteMedia' => new ApiCall($this->deleteMedia(...), 'media'), 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 * Checking the permissions of another user is restricted to superusers. 205 * 206 * Read the link below to learn more about the permission levels. 207 * 208 * @link https://www.dokuwiki.org/acl#background_info 209 * @param string $page A page or media ID 210 * @param string $user username 211 * @param string[] $groups array of groups 212 * @return int permission level 213 * @throws AccessDeniedException 214 * @throws RemoteException 215 */ 216 public function aclCheck($page, $user = '', $groups = []) 217 { 218 /** @var AuthPlugin $auth */ 219 global $auth; 220 221 $page = $this->checkPage($page, 0, false, AUTH_NONE); 222 223 if ($user === '') { 224 return auth_quickaclcheck($page); 225 } else { 226 // checking another user's permissions discloses their ACL posture, restrict to superusers 227 if (!$this->isSelf($user) && !auth_isadmin()) { 228 throw new AccessDeniedException('Only admins are allowed to check ACL for other users', 114); 229 } 230 if ($groups === []) { 231 $userinfo = $auth->getUserData($user); 232 if ($userinfo === false) { 233 $groups = []; 234 } else { 235 $groups = $userinfo['grps']; 236 } 237 } 238 return auth_aclcheck($page, $user, $groups); 239 } 240 } 241 242 /** 243 * Check whether the given user is the currently logged-in user 244 * 245 * The comparison normalizes both names the same way the ACL machinery matches 246 * them, so on a case-insensitive backend a differently-cased spelling of the 247 * current user is still recognized as themselves. 248 * 249 * @param string $user username to compare against the current user 250 * @return bool 251 */ 252 protected function isSelf($user) 253 { 254 /** @var AuthPlugin $auth */ 255 global $auth; 256 global $INPUT; 257 258 $curUser = $INPUT->server->str('REMOTE_USER'); 259 if (!$auth->isCaseSensitive()) { 260 $user = PhpString::strtolower($user); 261 $curUser = PhpString::strtolower($curUser); 262 } 263 return $auth->cleanUser($user) === $auth->cleanUser($curUser); 264 } 265 266 // endregion 267 268 // region pages 269 270 /** 271 * List all pages in the given namespace (and below) 272 * 273 * Setting the `depth` to `0` and the `namespace` to `""` will return all pages in the wiki. 274 * 275 * Note: author information is not available in this call. 276 * 277 * @param string $namespace The namespace to search. Empty string for root namespace 278 * @param int $depth How deep to search. 0 for all subnamespaces 279 * @param bool $hash Whether to include a MD5 hash of the page content 280 * @return Page[] A list of matching pages 281 * @todo might be a good idea to replace search_allpages with search_universal 282 */ 283 public function listPages($namespace = '', $depth = 1, $hash = false) 284 { 285 global $conf; 286 287 $namespace = cleanID($namespace); 288 289 // shortcut for all pages 290 if ($namespace === '' && $depth === 0) { 291 return $this->getAllPages($hash); 292 } 293 294 // search_allpages handles depth weird, we need to add the given namespace depth 295 if ($depth) { 296 $depth += substr_count($namespace, ':') + 1; 297 } 298 299 // run our search iterator to get the pages 300 $dir = utf8_encodeFN(str_replace(':', '/', $namespace)); 301 $data = []; 302 $opts['skipacl'] = 0; 303 $opts['depth'] = $depth; 304 $opts['hash'] = $hash; 305 search($data, $conf['datadir'], 'search_allpages', $opts, $dir); 306 307 return array_map(static fn($item) => new Page( 308 $item['id'], 309 0, // we're searching current revisions only 310 $item['mtime'], 311 '', // not returned by search_allpages 312 $item['size'], 313 null, // not returned by search_allpages 314 $item['hash'] ?? '' 315 ), $data); 316 } 317 318 /** 319 * Get all pages at once 320 * 321 * This is uses the page index and is quicker than iterating which is done in listPages() 322 * 323 * @return Page[] A list of all pages 324 * @see listPages() 325 */ 326 protected function getAllPages($hash = false) 327 { 328 $list = []; 329 $pages = (new Indexer())->getAllPages(); 330 Sort::ksort($pages); 331 332 foreach (array_keys($pages) as $idx) { 333 $perm = auth_quickaclcheck($pages[$idx]); 334 if ($perm < AUTH_READ || isHiddenPage($pages[$idx]) || !page_exists($pages[$idx])) { 335 continue; 336 } 337 338 $page = new Page($pages[$idx], 0, 0, '', null, $perm); 339 if ($hash) $page->calculateHash(); 340 341 $list[] = $page; 342 } 343 344 return $list; 345 } 346 347 /** 348 * Do a fulltext search 349 * 350 * This executes a full text search and returns the results. The query uses the standard 351 * DokuWiki search syntax. 352 * 353 * Snippets are provided for the first 15 results only. The title is either the first heading 354 * or the page id depending on the wiki's configuration. 355 * 356 * @link https://www.dokuwiki.org/search#syntax 357 * @param string $query The search query as supported by the DokuWiki search 358 * @return PageHit[] A list of matching pages 359 */ 360 public function searchPages($query) 361 { 362 $regex = []; 363 $FulltextSearch = new FulltextSearch(); 364 $data = $FulltextSearch->pageSearch($query, $regex); 365 $pages = []; 366 367 // prepare additional data 368 $idx = 0; 369 foreach ($data as $id => $score) { 370 if ($idx < $FulltextSearch->getMaxSnippets()) { 371 $snippet = $FulltextSearch->snippet($id, $regex); 372 $idx++; 373 } else { 374 $snippet = ''; 375 } 376 377 $pages[] = new PageHit( 378 $id, 379 $snippet, 380 $score, 381 useHeading('navigation') ? p_get_first_heading($id) : $id 382 ); 383 } 384 return $pages; 385 } 386 387 /** 388 * Get recent page changes 389 * 390 * Returns a list of recent changes to wiki pages. The results can be limited to changes newer than 391 * a given timestamp. 392 * 393 * Only changes within the configured `$conf['recent']` range are returned. This is the default 394 * when no timestamp is given. 395 * 396 * @link https://www.dokuwiki.org/config:recent 397 * @param int $timestamp Only show changes newer than this unix timestamp 398 * @return PageChange[] 399 * @author Michael Klier <chi@chimeric.de> 400 * @author Michael Hamann <michael@content-space.de> 401 */ 402 public function getRecentPageChanges($timestamp = 0) 403 { 404 $recents = getRecentsSince($timestamp); 405 406 $changes = []; 407 foreach ($recents as $recent) { 408 $changes[] = new PageChange( 409 $recent['id'], 410 $recent['date'], 411 $recent['user'], 412 $recent['ip'], 413 $recent['sum'], 414 $recent['type'], 415 $recent['sizechange'] 416 ); 417 } 418 419 return $changes; 420 } 421 422 /** 423 * Get a wiki page's syntax 424 * 425 * Returns the syntax of the given page. When no revision is given, the current revision is returned. 426 * 427 * A non-existing page (or revision) will return an empty string usually. For the current revision 428 * a page template will be returned if configured. 429 * 430 * Read access is required for the page. 431 * 432 * @param string $page wiki page id 433 * @param int $rev Revision timestamp to access an older revision 434 * @return string the syntax of the page 435 * @throws AccessDeniedException 436 * @throws RemoteException 437 */ 438 public function getPage($page, $rev = 0) 439 { 440 $page = $this->checkPage($page, $rev, false); 441 442 $text = rawWiki($page, $rev); 443 if (!$text && !$rev) { 444 return pageTemplate($page); 445 } else { 446 return $text; 447 } 448 } 449 450 /** 451 * Return a wiki page rendered to HTML 452 * 453 * The page is rendered to HTML as it would be in the wiki. The HTML consist only of the data for the page 454 * content itself, no surrounding structural tags, header, footers, sidebars etc are returned. 455 * 456 * References in the HTML are relative to the wiki base URL unless the `canonical` configuration is set. 457 * 458 * If the page does not exist, an error is returned. 459 * 460 * @link https://www.dokuwiki.org/config:canonical 461 * @param string $page page id 462 * @param int $rev revision timestamp 463 * @return string Rendered HTML for the page 464 * @throws AccessDeniedException 465 * @throws RemoteException 466 */ 467 public function getPageHTML($page, $rev = 0) 468 { 469 $page = $this->checkPage($page, $rev); 470 471 return (string)p_wiki_xhtml($page, $rev, false); 472 } 473 474 /** 475 * Return some basic data about a page 476 * 477 * The call will return an error if the requested page does not exist. 478 * 479 * Read access is required for the page. 480 * 481 * @param string $page page id 482 * @param int $rev revision timestamp 483 * @param bool $author whether to include the author information 484 * @param bool $hash whether to include the MD5 hash of the page content 485 * @return Page 486 * @throws AccessDeniedException 487 * @throws RemoteException 488 */ 489 public function getPageInfo($page, $rev = 0, $author = false, $hash = false) 490 { 491 $page = $this->checkPage($page, $rev); 492 493 $result = new Page($page, $rev); 494 if ($author) $result->retrieveAuthor(); 495 if ($hash) $result->calculateHash(); 496 497 return $result; 498 } 499 500 /** 501 * Returns a list of available revisions of a given wiki page 502 * 503 * The number of returned pages is set by `$conf['recent']`, but non accessible revisions 504 * are skipped, so less than that may be returned. 505 * 506 * @link https://www.dokuwiki.org/config:recent 507 * @param string $page page id 508 * @param int $first skip the first n changelog lines, 0 starts at the current revision 509 * @return PageChange[] 510 * @throws AccessDeniedException 511 * @throws RemoteException 512 * @author Michael Klier <chi@chimeric.de> 513 */ 514 public function getPageHistory($page, $first = 0) 515 { 516 global $conf; 517 518 $page = $this->checkPage($page, 0, false); 519 520 $pagelog = new PageChangeLog($page); 521 $pagelog->setChunkSize(1024); 522 // old revisions are counted from 0, so we need to subtract 1 for the current one 523 $revisions = $pagelog->getRevisions($first - 1, $conf['recent']); 524 525 $result = []; 526 foreach ($revisions as $rev) { 527 if (!page_exists($page, $rev)) continue; // skip non-existing revisions 528 $info = $pagelog->getRevisionInfo($rev); 529 530 $result[] = new PageChange( 531 $page, 532 $rev, 533 $info['user'], 534 $info['ip'], 535 $info['sum'], 536 $info['type'], 537 $info['sizechange'] 538 ); 539 } 540 541 return $result; 542 } 543 544 /** 545 * Get a page's links 546 * 547 * This returns a list of links found in the given page. This includes internal, external and interwiki links 548 * 549 * If a link occurs multiple times on the page, it will be returned multiple times. 550 * 551 * Read access for the given page is needed and page has to exist. 552 * 553 * @param string $page page id 554 * @return Link[] A list of links found on the given page 555 * @throws AccessDeniedException 556 * @throws RemoteException 557 * @todo returning link titles would be a nice addition 558 * @todo hash handling seems not to be correct 559 * @todo maybe return the same link only once? 560 * @author Michael Klier <chi@chimeric.de> 561 */ 562 public function getPageLinks($page) 563 { 564 $page = $this->checkPage($page); 565 566 // resolve page instructions 567 $ins = p_cached_instructions(wikiFN($page), false, $page); 568 569 // instantiate new Renderer - needed for interwiki links 570 $Renderer = new Doku_Renderer_xhtml(); 571 $Renderer->interwiki = getInterwiki(); 572 573 // parse instructions 574 $links = []; 575 foreach ($ins as $in) { 576 switch ($in[0]) { 577 case 'internallink': 578 $links[] = new Link('local', $in[1][0], wl($in[1][0])); 579 break; 580 case 'externallink': 581 $links[] = new Link('extern', $in[1][0], $in[1][0]); 582 break; 583 case 'interwikilink': 584 $url = $Renderer->_resolveInterWiki($in[1][2], $in[1][3]); 585 $links[] = new Link('interwiki', $in[1][0], $url); 586 break; 587 } 588 } 589 590 return ($links); 591 } 592 593 /** 594 * Get a page's backlinks 595 * 596 * A backlink is a wiki link on another page that links to the given page. 597 * 598 * Only links from pages readable by the current user are returned. The page itself 599 * needs to be readable. Otherwise an error is returned. 600 * 601 * @param string $page page id 602 * @return string[] A list of pages linking to the given page 603 * @throws AccessDeniedException 604 * @throws RemoteException 605 */ 606 public function getPageBackLinks($page) 607 { 608 $page = $this->checkPage($page, 0, false); 609 return (new MetadataSearch())->backlinks($page); 610 } 611 612 /** 613 * Lock the given set of pages 614 * 615 * This call will try to lock all given pages. It will return a list of pages that were 616 * successfully locked. If a page could not be locked, eg. because a different user is 617 * currently holding a lock, that page will be missing from the returned list. 618 * 619 * You should always ensure that the list of returned pages matches the given list of 620 * pages. It's up to you to decide how to handle failed locking. 621 * 622 * Note: you can only lock pages that you have write access for. It is possible to create 623 * a lock for a page that does not exist, yet. 624 * 625 * Note: it is not necessary to lock a page before saving it. The `savePage()` call will 626 * automatically lock and unlock the page for you. However if you plan to do related 627 * operations on multiple pages, locking them all at once beforehand can be useful. 628 * 629 * @param string[] $pages A list of pages to lock 630 * @return string[] A list of pages that were successfully locked 631 */ 632 public function lockPages($pages) 633 { 634 $locked = []; 635 636 foreach ($pages as $id) { 637 $id = cleanID($id); 638 if ($id === '') continue; 639 if (auth_quickaclcheck($id) < AUTH_EDIT || checklock($id)) { 640 continue; 641 } 642 lock($id); 643 $locked[] = $id; 644 } 645 return $locked; 646 } 647 648 /** 649 * Unlock the given set of pages 650 * 651 * This call will try to unlock all given pages. It will return a list of pages that were 652 * successfully unlocked. If a page could not be unlocked, eg. because a different user is 653 * currently holding a lock, that page will be missing from the returned list. 654 * 655 * You should always ensure that the list of returned pages matches the given list of 656 * pages. It's up to you to decide how to handle failed unlocking. 657 * 658 * Note: you can only unlock pages that you have write access for. 659 * 660 * @param string[] $pages A list of pages to unlock 661 * @return string[] A list of pages that were successfully unlocked 662 */ 663 public function unlockPages($pages) 664 { 665 $unlocked = []; 666 667 foreach ($pages as $id) { 668 $id = cleanID($id); 669 if ($id === '') continue; 670 if (auth_quickaclcheck($id) < AUTH_EDIT || !unlock($id)) { 671 continue; 672 } 673 $unlocked[] = $id; 674 } 675 676 return $unlocked; 677 } 678 679 /** 680 * Save a wiki page 681 * 682 * Saves the given wiki text to the given page. If the page does not exist, it will be created. 683 * Just like in the wiki, saving an empty text will delete the page. 684 * 685 * You need write permissions for the given page and the page may not be locked by another user. 686 * 687 * @param string $page page id 688 * @param string $text wiki text 689 * @param string $summary edit summary 690 * @param bool $isminor whether this is a minor edit 691 * @return bool Returns true on success 692 * @throws AccessDeniedException no write access for page 693 * @throws RemoteException no id, empty new page or locked 694 * @author Michael Klier <chi@chimeric.de> 695 */ 696 public function savePage($page, $text, $summary = '', $isminor = false) 697 { 698 global $TEXT; 699 global $lang; 700 701 $page = $this->checkPage($page, 0, false, AUTH_EDIT); 702 $TEXT = cleanText($text); 703 704 705 if (!page_exists($page) && trim($TEXT) == '') { 706 throw new RemoteException('Refusing to write an empty new wiki page', 132); 707 } 708 709 // Check, if page is locked 710 if (checklock($page)) { 711 throw new RemoteException('The page is currently locked', 133); 712 } 713 714 // SPAM check 715 if (checkwordblock()) { 716 throw new RemoteException('The page content was blocked', 134); 717 } 718 719 // autoset summary on new pages 720 if (!page_exists($page) && empty($summary)) { 721 $summary = $lang['created']; 722 } 723 724 // autoset summary on deleted pages 725 if (page_exists($page) && empty($TEXT) && empty($summary)) { 726 $summary = $lang['deleted']; 727 } 728 729 // FIXME auto set a summary in other cases "API Edit" might be a good idea? 730 731 lock($page); 732 saveWikiText($page, $TEXT, $summary, $isminor); 733 unlock($page); 734 735 // run the indexer if page wasn't indexed yet 736 try { 737 (new Indexer())->addPage($page); 738 } catch (\Exception) { 739 // indexing failure is non-fatal, the page was saved successfully 740 } 741 742 return true; 743 } 744 745 /** 746 * Appends text to the end of a wiki page 747 * 748 * If the page does not exist, it will be created. If a page template for the non-existant 749 * page is configured, the given text will appended to that template. 750 * 751 * The call will create a new page revision. 752 * 753 * You need write permissions for the given page. 754 * 755 * @param string $page page id 756 * @param string $text wiki text 757 * @param string $summary edit summary 758 * @param bool $isminor whether this is a minor edit 759 * @return bool Returns true on success 760 * @throws AccessDeniedException 761 * @throws RemoteException 762 */ 763 public function appendPage($page, $text, $summary = '', $isminor = false) 764 { 765 $currentpage = $this->getPage($page); 766 if (!is_string($currentpage)) { 767 $currentpage = ''; 768 } 769 return $this->savePage($page, $currentpage . $text, $summary, $isminor); 770 } 771 772 // endregion 773 774 // region media 775 776 /** 777 * List all media files in the given namespace (and below) 778 * 779 * Setting the `depth` to `0` and the `namespace` to `""` will return all media files in the wiki. 780 * 781 * When `pattern` is given, it needs to be a valid regular expression as understood by PHP's 782 * `preg_match()` including delimiters. 783 * The pattern is matched against the full media ID, including the namespace. 784 * 785 * @link https://www.php.net/manual/en/reference.pcre.pattern.syntax.php 786 * @param string $namespace The namespace to search. Empty string for root namespace 787 * @param string $pattern A regular expression to filter the returned files 788 * @param int $depth How deep to search. 0 for all subnamespaces 789 * @param bool $hash Whether to include a MD5 hash of the media content 790 * @return Media[] 791 * @author Gina Haeussge <osd@foosel.net> 792 */ 793 public function listMedia($namespace = '', $pattern = '', $depth = 1, $hash = false) 794 { 795 global $conf; 796 797 $namespace = cleanID($namespace); 798 799 $options = [ 800 'skipacl' => 0, 801 'depth' => $depth, 802 'hash' => $hash, 803 'pattern' => $pattern, 804 ]; 805 806 $dir = utf8_encodeFN(str_replace(':', '/', $namespace)); 807 $data = []; 808 search($data, $conf['mediadir'], 'search_media', $options, $dir); 809 return array_map(static fn($item) => new Media( 810 $item['id'], 811 0, // we're searching current revisions only 812 $item['mtime'], 813 $item['size'], 814 $item['perm'], 815 $item['isimg'], 816 $item['hash'] ?? '' 817 ), $data); 818 } 819 820 /** 821 * Get recent media changes 822 * 823 * Returns a list of recent changes to media files. The results can be limited to changes newer than 824 * a given timestamp. 825 * 826 * Only changes within the configured `$conf['recent']` range are returned. This is the default 827 * when no timestamp is given. 828 * 829 * @link https://www.dokuwiki.org/config:recent 830 * @param int $timestamp Only show changes newer than this unix timestamp 831 * @return MediaChange[] 832 * @author Michael Klier <chi@chimeric.de> 833 * @author Michael Hamann <michael@content-space.de> 834 */ 835 public function getRecentMediaChanges($timestamp = 0) 836 { 837 838 $recents = getRecentsSince($timestamp, null, '', RECENTS_MEDIA_CHANGES); 839 840 $changes = []; 841 foreach ($recents as $recent) { 842 $changes[] = new MediaChange( 843 $recent['id'], 844 $recent['date'], 845 $recent['user'], 846 $recent['ip'], 847 $recent['sum'], 848 $recent['type'], 849 $recent['sizechange'] 850 ); 851 } 852 853 return $changes; 854 } 855 856 /** 857 * Get a media file's content 858 * 859 * Returns the content of the given media file. When no revision is given, the current revision is returned. 860 * 861 * @link https://en.wikipedia.org/wiki/Base64 862 * @param string $media file id 863 * @param int $rev revision timestamp 864 * @return string Base64 encoded media file contents 865 * @throws AccessDeniedException no permission for media 866 * @throws RemoteException not exist 867 * @author Gina Haeussge <osd@foosel.net> 868 * 869 */ 870 public function getMedia($media, $rev = 0) 871 { 872 $media = cleanID($media); 873 if (auth_quickaclcheck(mediaAclPath($media)) < AUTH_READ) { 874 throw new AccessDeniedException('You are not allowed to read this media file', 211); 875 } 876 877 // was the current revision requested? 878 if ($this->isCurrentMediaRev($media, $rev)) { 879 $rev = 0; 880 } 881 882 $file = mediaFN($media, $rev); 883 if (!@ file_exists($file)) { 884 throw new RemoteException('The requested media file (revision) does not exist', 221); 885 } 886 887 $data = io_readFile($file, false); 888 return base64_encode($data); 889 } 890 891 /** 892 * Return info about a media file 893 * 894 * The call will return an error if the requested media file does not exist. 895 * 896 * Read access is required for the media file. 897 * 898 * @param string $media file id 899 * @param int $rev revision timestamp 900 * @param bool $author whether to include the author information 901 * @param bool $hash whether to include the MD5 hash of the media content 902 * @return Media 903 * @throws AccessDeniedException no permission for media 904 * @throws RemoteException if not exist 905 * @author Gina Haeussge <osd@foosel.net> 906 */ 907 public function getMediaInfo($media, $rev = 0, $author = false, $hash = false) 908 { 909 $media = cleanID($media); 910 if (auth_quickaclcheck(mediaAclPath($media)) < AUTH_READ) { 911 throw new AccessDeniedException('You are not allowed to read this media file', 211); 912 } 913 914 // was the current revision requested? 915 if ($this->isCurrentMediaRev($media, $rev)) { 916 $rev = 0; 917 } 918 919 if (!media_exists($media, $rev)) { 920 throw new RemoteException('The requested media file does not exist', 221); 921 } 922 923 $info = new Media($media, $rev); 924 if ($hash) $info->calculateHash(); 925 if ($author) $info->retrieveAuthor(); 926 927 return $info; 928 } 929 930 /** 931 * Returns the pages that use a given media file 932 * 933 * The call will return an error if the requested media file does not exist. 934 * 935 * Read access is required for the media file. 936 * 937 * Since API Version 13 938 * 939 * @param string $media file id 940 * @return string[] A list of pages linking to the given page 941 * @throws AccessDeniedException no permission for media 942 * @throws RemoteException if not exist 943 */ 944 public function getMediaUsage($media) 945 { 946 $media = cleanID($media); 947 if (auth_quickaclcheck(mediaAclPath($media)) < AUTH_READ) { 948 throw new AccessDeniedException('You are not allowed to read this media file', 211); 949 } 950 if (!media_exists($media)) { 951 throw new RemoteException('The requested media file does not exist', 221); 952 } 953 954 return (new MetadataSearch())->mediause($media); 955 } 956 957 /** 958 * Returns a list of available revisions of a given media file 959 * 960 * The number of returned files is set by `$conf['recent']`, but non accessible revisions 961 * are skipped, so less than that may be returned. 962 * 963 * Since API Version 14 964 * 965 * @link https://www.dokuwiki.org/config:recent 966 * @param string $media file id 967 * @param int $first skip the first n changelog lines, 0 starts at the current revision 968 * @return MediaChange[] 969 * @throws AccessDeniedException 970 * @throws RemoteException 971 * @author 972 */ 973 public function getMediaHistory($media, $first = 0) 974 { 975 global $conf; 976 977 $media = cleanID($media); 978 // check that this media exists 979 if (auth_quickaclcheck(mediaAclPath($media)) < AUTH_READ) { 980 throw new AccessDeniedException('You are not allowed to read this media file', 211); 981 } 982 if (!media_exists($media, 0)) { 983 throw new RemoteException('The requested media file does not exist', 221); 984 } 985 986 $medialog = new MediaChangeLog($media); 987 $medialog->setChunkSize(1024); 988 // old revisions are counted from 0, so we need to subtract 1 for the current one 989 $revisions = $medialog->getRevisions($first - 1, $conf['recent']); 990 991 $result = []; 992 foreach ($revisions as $rev) { 993 // the current revision needs to be checked against the current file path 994 $check = $this->isCurrentMediaRev($media, $rev) ? '' : $rev; 995 if (!media_exists($media, $check)) continue; // skip non-existing revisions 996 997 $info = $medialog->getRevisionInfo($rev); 998 999 $result[] = new MediaChange( 1000 $media, 1001 $rev, 1002 $info['user'], 1003 $info['ip'], 1004 $info['sum'], 1005 $info['type'], 1006 $info['sizechange'] 1007 ); 1008 } 1009 1010 return $result; 1011 } 1012 1013 /** 1014 * Uploads a file to the wiki 1015 * 1016 * The file data has to be passed as a base64 encoded string. 1017 * 1018 * @link https://en.wikipedia.org/wiki/Base64 1019 * @param string $media media id 1020 * @param string $base64 Base64 encoded file contents 1021 * @param bool $overwrite Should an existing file be overwritten? 1022 * @return bool Should always be true 1023 * @throws RemoteException 1024 * @author Michael Klier <chi@chimeric.de> 1025 */ 1026 public function saveMedia($media, $base64, $overwrite = false) 1027 { 1028 $media = cleanID($media); 1029 $auth = auth_quickaclcheck(mediaAclPath($media)); 1030 1031 if ($media === '') { 1032 throw new RemoteException('Empty or invalid media ID given', 231); 1033 } 1034 1035 // clean up base64 encoded data 1036 $base64 = strtr($base64, [ 1037 "\n" => '', // strip newlines 1038 "\r" => '', // strip carriage returns 1039 '-' => '+', // RFC4648 base64url 1040 '_' => '/', // RFC4648 base64url 1041 ' ' => '+', // JavaScript data uri 1042 ]); 1043 1044 $data = base64_decode($base64, true); 1045 if ($data === false) { 1046 throw new RemoteException('Invalid base64 encoded data', 234); 1047 } 1048 1049 if ($data === '') { 1050 throw new RemoteException('Empty file given', 235); 1051 } 1052 1053 // save temporary file 1054 global $conf; 1055 $ftmp = $conf['tmpdir'] . '/' . md5($media . clientIP()); 1056 @unlink($ftmp); 1057 io_saveFile($ftmp, $data); 1058 1059 $res = media_save(['name' => $ftmp], $media, $overwrite, $auth, 'rename'); 1060 if (is_array($res)) { 1061 throw new RemoteException('Failed to save media: ' . $res[0], 236); 1062 } 1063 return (bool)$res; // should always be true at this point 1064 } 1065 1066 /** 1067 * Deletes a file from the wiki 1068 * 1069 * You need to have delete permissions for the file. 1070 * 1071 * @param string $media media id 1072 * @return bool Should always be true 1073 * @throws AccessDeniedException no permissions 1074 * @throws RemoteException file in use or not deleted 1075 * @author Gina Haeussge <osd@foosel.net> 1076 * 1077 */ 1078 public function deleteMedia($media) 1079 { 1080 $media = cleanID($media); 1081 1082 $auth = auth_quickaclcheck(mediaAclPath($media)); 1083 $res = media_delete($media, $auth); 1084 if ($res & DOKU_MEDIA_DELETED) { 1085 return true; 1086 } elseif ($res & DOKU_MEDIA_NOT_AUTH) { 1087 throw new AccessDeniedException('You are not allowed to delete this media file', 212); 1088 } elseif ($res & DOKU_MEDIA_INUSE) { 1089 throw new RemoteException('Media file is still referenced', 232); 1090 } elseif (!media_exists($media)) { 1091 throw new RemoteException('The media file requested to delete does not exist', 221); 1092 } else { 1093 throw new RemoteException('Failed to delete media file', 233); 1094 } 1095 } 1096 1097 /** 1098 * Check if the given revision is the current revision of this file 1099 * 1100 * @param string $id 1101 * @param int $rev 1102 * @return bool 1103 */ 1104 protected function isCurrentMediaRev(string $id, int $rev) 1105 { 1106 $current = @filemtime(mediaFN($id)); 1107 if ($current === $rev) return true; 1108 return false; 1109 } 1110 1111 // endregion 1112 1113 1114 /** 1115 * Convenience method for page checks 1116 * 1117 * This method will perform multiple tasks: 1118 * 1119 * - clean the given page id 1120 * - disallow an empty page id 1121 * - check if the page exists (unless disabled) 1122 * - check if the user has the required access level (pass AUTH_NONE to skip) 1123 * 1124 * @param string $id page id 1125 * @param int $rev page revision 1126 * @param bool $existCheck 1127 * @param int $minAccess 1128 * @return string the cleaned page id 1129 * @throws AccessDeniedException 1130 * @throws RemoteException 1131 */ 1132 private function checkPage($id, $rev = 0, $existCheck = true, $minAccess = AUTH_READ) 1133 { 1134 $id = cleanID($id); 1135 if ($id === '') { 1136 throw new RemoteException('Empty or invalid page ID given', 131); 1137 } 1138 1139 if ($existCheck && !page_exists($id, $rev)) { 1140 throw new RemoteException('The requested page (revision) does not exist', 121); 1141 } 1142 1143 if ($minAccess && auth_quickaclcheck($id) < $minAccess) { 1144 throw new AccessDeniedException('You are not allowed to read this page', 111); 1145 } 1146 1147 return $id; 1148 } 1149} 1150