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