[ Index ]

PHP Cross Reference of DokuWiki

title

Body

[close]

/inc/Remote/ -> ApiCore.php (source)

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