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