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())->getPages(); 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 (new Indexer($page))->addPage(); 705 706 return true; 707 } 708 709 /** 710 * Appends text to the end of a wiki page 711 * 712 * If the page does not exist, it will be created. If a page template for the non-existant 713 * page is configured, the given text will appended to that template. 714 * 715 * The call will create a new page revision. 716 * 717 * You need write permissions for the given page. 718 * 719 * @param string $page page id 720 * @param string $text wiki text 721 * @param string $summary edit summary 722 * @param bool $isminor whether this is a minor edit 723 * @return bool Returns true on success 724 * @throws AccessDeniedException 725 * @throws RemoteException 726 */ 727 public function appendPage($page, $text, $summary = '', $isminor = false) 728 { 729 $currentpage = $this->getPage($page); 730 if (!is_string($currentpage)) { 731 $currentpage = ''; 732 } 733 return $this->savePage($page, $currentpage . $text, $summary, $isminor); 734 } 735 736 // endregion 737 738 // region media 739 740 /** 741 * List all media files in the given namespace (and below) 742 * 743 * Setting the `depth` to `0` and the `namespace` to `""` will return all media files in the wiki. 744 * 745 * When `pattern` is given, it needs to be a valid regular expression as understood by PHP's 746 * `preg_match()` including delimiters. 747 * The pattern is matched against the full media ID, including the namespace. 748 * 749 * @link https://www.php.net/manual/en/reference.pcre.pattern.syntax.php 750 * @param string $namespace The namespace to search. Empty string for root namespace 751 * @param string $pattern A regular expression to filter the returned files 752 * @param int $depth How deep to search. 0 for all subnamespaces 753 * @param bool $hash Whether to include a MD5 hash of the media content 754 * @return Media[] 755 * @author Gina Haeussge <osd@foosel.net> 756 */ 757 public function listMedia($namespace = '', $pattern = '', $depth = 1, $hash = false) 758 { 759 global $conf; 760 761 $namespace = cleanID($namespace); 762 763 $options = [ 764 'skipacl' => 0, 765 'depth' => $depth, 766 'hash' => $hash, 767 'pattern' => $pattern, 768 ]; 769 770 $dir = utf8_encodeFN(str_replace(':', '/', $namespace)); 771 $data = []; 772 search($data, $conf['mediadir'], 'search_media', $options, $dir); 773 return array_map(static fn($item) => new Media( 774 $item['id'], 775 0, // we're searching current revisions only 776 $item['mtime'], 777 $item['size'], 778 $item['perm'], 779 $item['isimg'], 780 $item['hash'] ?? '' 781 ), $data); 782 } 783 784 /** 785 * Get recent media changes 786 * 787 * Returns a list of recent changes to media files. The results can be limited to changes newer than 788 * a given timestamp. 789 * 790 * Only changes within the configured `$conf['recent']` range are returned. This is the default 791 * when no timestamp is given. 792 * 793 * @link https://www.dokuwiki.org/config:recent 794 * @param int $timestamp Only show changes newer than this unix timestamp 795 * @return MediaChange[] 796 * @author Michael Klier <chi@chimeric.de> 797 * @author Michael Hamann <michael@content-space.de> 798 */ 799 public function getRecentMediaChanges($timestamp = 0) 800 { 801 802 $recents = getRecentsSince($timestamp, null, '', RECENTS_MEDIA_CHANGES); 803 804 $changes = []; 805 foreach ($recents as $recent) { 806 $changes[] = new MediaChange( 807 $recent['id'], 808 $recent['date'], 809 $recent['user'], 810 $recent['ip'], 811 $recent['sum'], 812 $recent['type'], 813 $recent['sizechange'] 814 ); 815 } 816 817 return $changes; 818 } 819 820 /** 821 * Get a media file's content 822 * 823 * Returns the content of the given media file. When no revision is given, the current revision is returned. 824 * 825 * @link https://en.wikipedia.org/wiki/Base64 826 * @param string $media file id 827 * @param int $rev revision timestamp 828 * @return string Base64 encoded media file contents 829 * @throws AccessDeniedException no permission for media 830 * @throws RemoteException not exist 831 * @author Gina Haeussge <osd@foosel.net> 832 * 833 */ 834 public function getMedia($media, $rev = 0) 835 { 836 $media = cleanID($media); 837 if (auth_quickaclcheck($media) < AUTH_READ) { 838 throw new AccessDeniedException('You are not allowed to read this media file', 211); 839 } 840 841 // was the current revision requested? 842 if ($this->isCurrentMediaRev($media, $rev)) { 843 $rev = 0; 844 } 845 846 $file = mediaFN($media, $rev); 847 if (!@ file_exists($file)) { 848 throw new RemoteException('The requested media file (revision) does not exist', 221); 849 } 850 851 $data = io_readFile($file, false); 852 return base64_encode($data); 853 } 854 855 /** 856 * Return info about a media file 857 * 858 * The call will return an error if the requested media file does not exist. 859 * 860 * Read access is required for the media file. 861 * 862 * @param string $media file id 863 * @param int $rev revision timestamp 864 * @param bool $author whether to include the author information 865 * @param bool $hash whether to include the MD5 hash of the media content 866 * @return Media 867 * @throws AccessDeniedException no permission for media 868 * @throws RemoteException if not exist 869 * @author Gina Haeussge <osd@foosel.net> 870 */ 871 public function getMediaInfo($media, $rev = 0, $author = false, $hash = false) 872 { 873 $media = cleanID($media); 874 if (auth_quickaclcheck($media) < AUTH_READ) { 875 throw new AccessDeniedException('You are not allowed to read this media file', 211); 876 } 877 878 // was the current revision requested? 879 if ($this->isCurrentMediaRev($media, $rev)) { 880 $rev = 0; 881 } 882 883 if (!media_exists($media, $rev)) { 884 throw new RemoteException('The requested media file does not exist', 221); 885 } 886 887 $info = new Media($media, $rev); 888 if ($hash) $info->calculateHash(); 889 if ($author) $info->retrieveAuthor(); 890 891 return $info; 892 } 893 894 /** 895 * Returns the pages that use a given media file 896 * 897 * The call will return an error if the requested media file does not exist. 898 * 899 * Read access is required for the media file. 900 * 901 * Since API Version 13 902 * 903 * @param string $media file id 904 * @return string[] A list of pages linking to the given page 905 * @throws AccessDeniedException no permission for media 906 * @throws RemoteException if not exist 907 */ 908 public function getMediaUsage($media) 909 { 910 $media = cleanID($media); 911 if (auth_quickaclcheck($media) < AUTH_READ) { 912 throw new AccessDeniedException('You are not allowed to read this media file', 211); 913 } 914 if (!media_exists($media)) { 915 throw new RemoteException('The requested media file does not exist', 221); 916 } 917 918 return ft_mediause($media); 919 } 920 921 /** 922 * Returns a list of available revisions of a given media file 923 * 924 * The number of returned files is set by `$conf['recent']`, but non accessible revisions 925 * are skipped, so less than that may be returned. 926 * 927 * Since API Version 14 928 * 929 * @link https://www.dokuwiki.org/config:recent 930 * @param string $media file id 931 * @param int $first skip the first n changelog lines, 0 starts at the current revision 932 * @return MediaChange[] 933 * @throws AccessDeniedException 934 * @throws RemoteException 935 * @author 936 */ 937 public function getMediaHistory($media, $first = 0) 938 { 939 global $conf; 940 941 $media = cleanID($media); 942 // check that this media exists 943 if (auth_quickaclcheck($media) < AUTH_READ) { 944 throw new AccessDeniedException('You are not allowed to read this media file', 211); 945 } 946 if (!media_exists($media, 0)) { 947 throw new RemoteException('The requested media file does not exist', 221); 948 } 949 950 $medialog = new MediaChangeLog($media); 951 $medialog->setChunkSize(1024); 952 // old revisions are counted from 0, so we need to subtract 1 for the current one 953 $revisions = $medialog->getRevisions($first - 1, $conf['recent']); 954 955 $result = []; 956 foreach ($revisions as $rev) { 957 // the current revision needs to be checked against the current file path 958 $check = $this->isCurrentMediaRev($media, $rev) ? '' : $rev; 959 if (!media_exists($media, $check)) continue; // skip non-existing revisions 960 961 $info = $medialog->getRevisionInfo($rev); 962 963 $result[] = new MediaChange( 964 $media, 965 $rev, 966 $info['user'], 967 $info['ip'], 968 $info['sum'], 969 $info['type'], 970 $info['sizechange'] 971 ); 972 } 973 974 return $result; 975 } 976 977 /** 978 * Uploads a file to the wiki 979 * 980 * The file data has to be passed as a base64 encoded string. 981 * 982 * @link https://en.wikipedia.org/wiki/Base64 983 * @param string $media media id 984 * @param string $base64 Base64 encoded file contents 985 * @param bool $overwrite Should an existing file be overwritten? 986 * @return bool Should always be true 987 * @throws RemoteException 988 * @author Michael Klier <chi@chimeric.de> 989 */ 990 public function saveMedia($media, $base64, $overwrite = false) 991 { 992 $media = cleanID($media); 993 $auth = auth_quickaclcheck(getNS($media) . ':*'); 994 995 if ($media === '') { 996 throw new RemoteException('Empty or invalid media ID given', 231); 997 } 998 999 // clean up base64 encoded data 1000 $base64 = strtr($base64, [ 1001 "\n" => '', // strip newlines 1002 "\r" => '', // strip carriage returns 1003 '-' => '+', // RFC4648 base64url 1004 '_' => '/', // RFC4648 base64url 1005 ' ' => '+', // JavaScript data uri 1006 ]); 1007 1008 $data = base64_decode($base64, true); 1009 if ($data === false) { 1010 throw new RemoteException('Invalid base64 encoded data', 234); 1011 } 1012 1013 if ($data === '') { 1014 throw new RemoteException('Empty file given', 235); 1015 } 1016 1017 // save temporary file 1018 global $conf; 1019 $ftmp = $conf['tmpdir'] . '/' . md5($media . clientIP()); 1020 @unlink($ftmp); 1021 io_saveFile($ftmp, $data); 1022 1023 $res = media_save(['name' => $ftmp], $media, $overwrite, $auth, 'rename'); 1024 if (is_array($res)) { 1025 throw new RemoteException('Failed to save media: ' . $res[0], 236); 1026 } 1027 return (bool)$res; // should always be true at this point 1028 } 1029 1030 /** 1031 * Deletes a file from the wiki 1032 * 1033 * You need to have delete permissions for the file. 1034 * 1035 * @param string $media media id 1036 * @return bool Should always be true 1037 * @throws AccessDeniedException no permissions 1038 * @throws RemoteException file in use or not deleted 1039 * @author Gina Haeussge <osd@foosel.net> 1040 * 1041 */ 1042 public function deleteMedia($media) 1043 { 1044 $media = cleanID($media); 1045 1046 $auth = auth_quickaclcheck($media); 1047 $res = media_delete($media, $auth); 1048 if ($res & DOKU_MEDIA_DELETED) { 1049 return true; 1050 } elseif ($res & DOKU_MEDIA_NOT_AUTH) { 1051 throw new AccessDeniedException('You are not allowed to delete this media file', 212); 1052 } elseif ($res & DOKU_MEDIA_INUSE) { 1053 throw new RemoteException('Media file is still referenced', 232); 1054 } elseif (!media_exists($media)) { 1055 throw new RemoteException('The media file requested to delete does not exist', 221); 1056 } else { 1057 throw new RemoteException('Failed to delete media file', 233); 1058 } 1059 } 1060 1061 /** 1062 * Check if the given revision is the current revision of this file 1063 * 1064 * @param string $id 1065 * @param int $rev 1066 * @return bool 1067 */ 1068 protected function isCurrentMediaRev(string $id, int $rev) 1069 { 1070 $current = @filemtime(mediaFN($id)); 1071 if ($current === $rev) return true; 1072 return false; 1073 } 1074 1075 // endregion 1076 1077 1078 /** 1079 * Convenience method for page checks 1080 * 1081 * This method will perform multiple tasks: 1082 * 1083 * - clean the given page id 1084 * - disallow an empty page id 1085 * - check if the page exists (unless disabled) 1086 * - check if the user has the required access level (pass AUTH_NONE to skip) 1087 * 1088 * @param string $id page id 1089 * @param int $rev page revision 1090 * @param bool $existCheck 1091 * @param int $minAccess 1092 * @return string the cleaned page id 1093 * @throws AccessDeniedException 1094 * @throws RemoteException 1095 */ 1096 private function checkPage($id, $rev = 0, $existCheck = true, $minAccess = AUTH_READ) 1097 { 1098 $id = cleanID($id); 1099 if ($id === '') { 1100 throw new RemoteException('Empty or invalid page ID given', 131); 1101 } 1102 1103 if ($existCheck && !page_exists($id, $rev)) { 1104 throw new RemoteException('The requested page (revision) does not exist', 121); 1105 } 1106 1107 if ($minAccess && auth_quickaclcheck($id) < $minAccess) { 1108 throw new AccessDeniedException('You are not allowed to read this page', 111); 1109 } 1110 1111 return $id; 1112 } 1113} 1114