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