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