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