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