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