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