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