xref: /dokuwiki/inc/Remote/ApiCore.php (revision 902647e630bb637c62651241b186d7b5d5d810e2)
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\MediaRevision;
12use dokuwiki\Remote\Response\Page;
13use dokuwiki\Remote\Response\PageHit;
14use dokuwiki\Remote\Response\PageRevision;
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.getPageVersions' => new ApiCall([$this, 'getPageVersions'], '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            // todo: implement searchMedia
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            // todo: implement getMediaVersions
69            // todo: implement getMediaUsage
70
71            'core.saveMedia' => new ApiCall([$this, 'saveMedia'], 'media'),
72            'core.deleteMedia' => new ApiCall([$this, 'deleteMedia'], 'media'),
73        ];
74    }
75
76    // region info
77
78    /**
79     * Return the API version
80     *
81     * This is the version of the DokuWiki API. It increases whenever the API definition changes.
82     *
83     * When developing a client, you should check this version and make sure you can handle it.
84     *
85     * @return int
86     */
87    public function getAPIVersion()
88    {
89        return self::API_VERSION;
90    }
91
92    /**
93     * Returns the wiki title
94     *
95     * @link https://www.dokuwiki.org/config:title
96     * @return string
97     */
98    public function getWikiTitle()
99    {
100        global $conf;
101        return $conf['title'];
102    }
103
104    /**
105     * Return the current server time
106     *
107     * Returns a Unix timestamp (seconds since 1970-01-01 00:00:00 UTC).
108     *
109     * You can use this to compensate for differences between your client's time and the
110     * server's time when working with last modified timestamps (revisions).
111     *
112     * @return int A unix timestamp
113     */
114    public function getWikiTime()
115    {
116        return time();
117    }
118
119    // endregion
120
121    // region user
122
123    /**
124     * Login
125     *
126     * This will use the given credentials and attempt to login the user. This will set the
127     * appropriate cookies, which can be used for subsequent requests.
128     *
129     * Use of this mechanism is discouraged. Using token authentication is preferred.
130     *
131     * @param string $user The user name
132     * @param string $pass The password
133     * @return int If the login was successful
134     */
135    public function login($user, $pass)
136    {
137        global $conf;
138        /** @var AuthPlugin $auth */
139        global $auth;
140
141        if (!$conf['useacl']) return 0;
142        if (!$auth instanceof AuthPlugin) return 0;
143
144        @session_start(); // reopen session for login
145        $ok = null;
146        if ($auth->canDo('external')) {
147            $ok = $auth->trustExternal($user, $pass, false);
148        }
149        if ($ok === null) {
150            $evdata = [
151                'user' => $user,
152                'password' => $pass,
153                'sticky' => false,
154                'silent' => true
155            ];
156            $ok = Event::createAndTrigger('AUTH_LOGIN_CHECK', $evdata, 'auth_login_wrapper');
157        }
158        session_write_close(); // we're done with the session
159
160        return $ok;
161    }
162
163    /**
164     * Log off
165     *
166     * Attempt to log out the current user, deleting the appropriate cookies
167     *
168     * Use of this mechanism is discouraged. Using token authentication is preferred.
169     *
170     * @return int 0 on failure, 1 on success
171     */
172    public function logoff()
173    {
174        global $conf;
175        global $auth;
176        if (!$conf['useacl']) return 0;
177        if (!$auth instanceof AuthPlugin) return 0;
178
179        auth_logoff();
180
181        return 1;
182    }
183
184    /**
185     * Info about the currently authenticated user
186     *
187     * @return User
188     */
189    public function whoAmI()
190    {
191        return new User([]);
192    }
193
194    /**
195     * Check ACL Permissions
196     *
197     * This call allows to check the permissions for a given page/media and user/group combination.
198     * If no user/group is given, the current user is used.
199     *
200     * Read the link below to learn more about the permission levels.
201     *
202     * @link https://www.dokuwiki.org/acl#background_info
203     * @param string $page A page or media ID
204     * @param string $user username
205     * @param string[] $groups array of groups
206     * @return int permission level
207     * @throws RemoteException
208     */
209    public function aclCheck($page, $user = '', $groups = [])
210    {
211        /** @var AuthPlugin $auth */
212        global $auth;
213
214        $page = $this->checkPage($page, false, AUTH_NONE);
215
216        if ($user === '') {
217            return auth_quickaclcheck($page);
218        } else {
219            if ($groups === []) {
220                $userinfo = $auth->getUserData($user);
221                if ($userinfo === false) {
222                    $groups = [];
223                } else {
224                    $groups = $userinfo['grps'];
225                }
226            }
227            return auth_aclcheck($page, $user, $groups);
228        }
229    }
230
231    // endregion
232
233    // region pages
234
235    /**
236     * List all pages in the given namespace (and below)
237     *
238     * Setting the `depth` to `0` and the `namespace` to `""` will return all pages in the wiki.
239     *
240     * @param string $namespace The namespace to search. Empty string for root namespace
241     * @param int $depth How deep to search. 0 for all subnamespaces
242     * @param bool $hash Whether to include a MD5 hash of the page content
243     * @return Page[] A list of matching pages
244     */
245    public function listPages($namespace = '', $depth = 1, $hash = false)
246    {
247        global $conf;
248
249        $namespace = cleanID($namespace);
250
251        // shortcut for all pages
252        if ($namespace === '' && $depth === 0) {
253            return $this->getAllPages($hash);
254        }
255
256        // run our search iterator to get the pages
257        $dir = utf8_encodeFN(str_replace(':', '/', $namespace));
258        $data = [];
259        $opts['skipacl'] = 0;
260        $opts['depth'] = $depth; // FIXME depth needs to be calculated relative to $dir
261        $opts['hash'] = $hash;
262        search($data, $conf['datadir'], 'search_allpages', $opts, $dir);
263
264        return array_map(fn($item) => new Page($item), $data);
265    }
266
267    /**
268     * Get all pages at once
269     *
270     * This is uses the page index and is quicker than iterating which is done in listPages()
271     *
272     * @return Page[] A list of all pages
273     * @see listPages()
274     */
275    protected function getAllPages($hash = false)
276    {
277        $list = [];
278        $pages = idx_get_indexer()->getPages();
279        Sort::ksort($pages);
280
281        foreach (array_keys($pages) as $idx) {
282            $perm = auth_quickaclcheck($pages[$idx]);
283            if ($perm < AUTH_READ || isHiddenPage($pages[$idx]) || !page_exists($pages[$idx])) {
284                continue;
285            }
286
287            $page = new Page([
288                'id' => $pages[$idx],
289                'perm' => $perm,
290            ]);
291            if ($hash) $page->calculateHash();
292
293            $list[] = $page;
294        }
295
296        return $list;
297    }
298
299    /**
300     * Do a fulltext search
301     *
302     * This executes a full text search and returns the results. The query uses the standard
303     * DokuWiki search syntax.
304     *
305     * Snippets are provided for the first 15 results only. The title is either the first heading
306     * or the page id depending on the wiki's configuration.
307     *
308     * @link https://www.dokuwiki.org/search#syntax
309     * @param string $query The search query as supported by the DokuWiki search
310     * @return PageHit[] A list of matching pages
311     */
312    public function searchPages($query)
313    {
314        $regex = [];
315        $data = ft_pageSearch($query, $regex);
316        $pages = [];
317
318        // prepare additional data
319        $idx = 0;
320        foreach ($data as $id => $score) {
321            $file = wikiFN($id);
322
323            if ($idx < FT_SNIPPET_NUMBER) {
324                $snippet = ft_snippet($id, $regex);
325                $idx++;
326            } else {
327                $snippet = '';
328            }
329
330            $pages[] = new PageHit([
331                'id' => $id,
332                'score' => (int)$score,
333                'rev' => filemtime($file),
334                'mtime' => filemtime($file),
335                'size' => filesize($file),
336                'snippet' => $snippet,
337                'title' => useHeading('navigation') ? p_get_first_heading($id) : $id
338            ]);
339        }
340        return $pages;
341    }
342
343    /**
344     * Get recent page changes
345     *
346     * Returns a list of recent changes to wiki pages. The results can be limited to changes newer than
347     * a given timestamp.
348     *
349     * Only changes within the configured `$conf['recent']` range are returned. This is the default
350     * when no timestamp is given.
351     *
352     * @link https://www.dokuwiki.org/config:recent
353     * @param int $timestamp Only show changes newer than this unix timestamp
354     * @return PageRevision[]
355     * @author Michael Klier <chi@chimeric.de>
356     * @author Michael Hamann <michael@content-space.de>
357     */
358    public function getRecentPageChanges($timestamp = 0)
359    {
360        $recents = getRecentsSince($timestamp);
361
362        $changes = [];
363        foreach ($recents as $recent) {
364            $changes[] = new PageRevision([
365                'id' => $recent['id'],
366                'revision' => $recent['date'],
367                'author' => $recent['user'],
368                'ip' => $recent['ip'],
369                'summary' => $recent['sum'],
370                'type' => $recent['type'],
371                'sizechange' => $recent['sizechange'],
372            ]);
373        }
374
375        return $changes;
376    }
377
378    /**
379     * Get a wiki page's syntax
380     *
381     * Returns the syntax of the given page. When no revision is given, the current revision is returned.
382     *
383     * A non-existing page (or revision) will return an empty string usually. For the current revision
384     * a page template will be returned if configured.
385     *
386     * Read access is required for the page.
387     *
388     * @param string $page wiki page id
389     * @param string $rev Revision timestamp to access an older revision
390     * @return string the syntax of the page
391     * @throws AccessDeniedException
392     * @throws RemoteException
393     */
394    public function getPage($page, $rev = '')
395    {
396        $page = $this->checkPage($page, false);
397
398        $text = rawWiki($page, $rev);
399        if (!$text && !$rev) {
400            return pageTemplate($page);
401        } else {
402            return $text;
403        }
404    }
405
406    /**
407     * Return a wiki page rendered to HTML
408     *
409     * The page is rendered to HTML as it would be in the wiki. The HTML consist only of the data for the page
410     * content itself, no surrounding structural tags, header, footers, sidebars etc are returned.
411     *
412     * References in the HTML are relative to the wiki base URL unless the `canonical` configuration is set.
413     *
414     * If the page does not exist, an error is returned.
415     *
416     * @link https://www.dokuwiki.org/config:canonical
417     * @param string $page page id
418     * @param string $rev revision timestamp
419     * @return string Rendered HTML for the page
420     * @throws AccessDeniedException
421     * @throws RemoteException
422     */
423    public function getPageHTML($page, $rev = '')
424    {
425        $page = $this->checkPage($page);
426
427        return (string)p_wiki_xhtml($page, $rev, false);
428    }
429
430    /**
431     * Return some basic data about a page
432     *
433     * The call will return an error if the requested page does not exist.
434     *
435     * Read access is required for the page.
436     *
437     * @param string $page page id
438     * @param string $rev revision timestamp
439     * @param bool $author whether to include the author information
440     * @param bool $hash whether to include the MD5 hash of the page content
441     * @return Page
442     * @throws AccessDeniedException
443     * @throws RemoteException
444     */
445    public function getPageInfo($page, $rev = '', $author = false, $hash = false)
446    {
447        $page = $this->checkPage($page);
448
449        $result = new Page(['id' => $page, 'rev' => $rev]);
450        if ($author) $result->retrieveAuthor();
451        if ($hash) $result->calculateHash();
452
453        return $result;
454    }
455
456    /**
457     * Returns a list of available revisions of a given wiki page
458     *
459     * The number of returned pages is set by `$conf['recent']`, but non accessible revisions pages
460     * are skipped, so less than that may be returned.
461     *
462     * @link https://www.dokuwiki.org/config:recent
463     * @param string $page page id
464     * @param int $first skip the first n changelog lines, 0 starts at the current revision
465     * @return PageRevision[]
466     * @throws AccessDeniedException
467     * @throws RemoteException
468     * @author Michael Klier <chi@chimeric.de>
469     */
470    public function getPageVersions($page, $first = 0)
471    {
472        global $conf;
473
474        $page = $this->checkPage($page, false);
475
476        $pagelog = new PageChangeLog($page);
477        $pagelog->setChunkSize(1024);
478        // old revisions are counted from 0, so we need to subtract 1 for the current one
479        $revisions = $pagelog->getRevisions($first - 1, $conf['recent']);
480
481        $result = [];
482        foreach ($revisions as $rev) {
483            if (!page_exists($page, $rev)) continue; // skip non-existing revisions
484            $info = $pagelog->getRevisionInfo($rev);
485
486            $result[] = new PageRevision([
487                'id' => $page,
488                'revision' => $rev,
489                'author' => $info['user'],
490                'ip' => $info['ip'],
491                'summary' => $info['sum'],
492                'type' => $info['type'],
493                'sizechange' => $info['sizechange'],
494            ]);
495        }
496
497        return $result;
498    }
499
500    /**
501     * Get a page's links
502     *
503     * This returns a list of links found in the given page. This includes internal, external and interwiki links
504     *
505     * Read access for the given page is needed and page has to exist.
506     *
507     * @param string $page page id
508     * @return Link[] A list of links found on the given page
509     * @throws AccessDeniedException
510     * @throws RemoteException
511     * @todo returning link titles would be a nice addition
512     * @todo hash handling seems not to be correct
513     * @author Michael Klier <chi@chimeric.de>
514     */
515    public function getPageLinks($page)
516    {
517        $page = $this->checkPage($page);
518
519        // resolve page instructions
520        $ins = p_cached_instructions(wikiFN($page));
521
522        // instantiate new Renderer - needed for interwiki links
523        $Renderer = new Doku_Renderer_xhtml();
524        $Renderer->interwiki = getInterwiki();
525
526        // parse instructions
527        $links = [];
528        foreach ($ins as $in) {
529            switch ($in[0]) {
530                case 'internallink':
531                    $links[] = new Link([
532                        'type' => 'local',
533                        'page' => $in[1][0],
534                        'href' => wl($in[1][0]),
535                    ]);
536                    break;
537                case 'externallink':
538                    $links[] = new Link([
539                        'type' => 'extern',
540                        'page' => $in[1][0],
541                        'href' => $in[1][0],
542                    ]);
543                    break;
544                case 'interwikilink':
545                    $url = $Renderer->_resolveInterWiki($in[1][2], $in[1][3]);
546                    $links[] = new Link([
547                        'type' => 'interwiki',
548                        'page' => $in[1][0],
549                        'href' => $url,
550                    ]);
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, 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, 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('Positive wordblock check', 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)
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     * @throws AccessDeniedException no access to the media files
754     * @author Gina Haeussge <osd@foosel.net>
755     */
756    public function listMedia($namespace = '', $pattern = '', $depth = 1, $hash = false)
757    {
758        global $conf;
759
760        $namespace = cleanID($namespace);
761
762        if (auth_quickaclcheck($namespace . ':*') < AUTH_READ) {
763            throw new AccessDeniedException('You are not allowed to list media files.', 215);
764        }
765
766        $options = [
767            'skipacl' => 0,
768            'depth' => $depth,
769            'hash' => $hash,
770            'pattern' => $pattern,
771        ];
772
773        $dir = utf8_encodeFN(str_replace(':', '/', $namespace));
774        $data = [];
775        search($data, $conf['mediadir'], 'search_media', $options, $dir);
776        return array_map(fn($item) => new Media($item), $data);
777    }
778
779    /**
780     * Get recent media changes
781     *
782     * Returns a list of recent changes to media files. The results can be limited to changes newer than
783     * a given timestamp.
784     *
785     * Only changes within the configured `$conf['recent']` range are returned. This is the default
786     * when no timestamp is given.
787     *
788     * @link https://www.dokuwiki.org/config:recent
789     * @param int $timestamp Only show changes newer than this unix timestamp
790     * @return MediaRevision[]
791     * @author Michael Klier <chi@chimeric.de>
792     * @author Michael Hamann <michael@content-space.de>
793     */
794    public function getRecentMediaChanges($timestamp = 0)
795    {
796
797        $recents = getRecentsSince($timestamp, null, '', RECENTS_MEDIA_CHANGES);
798
799        $changes = [];
800        foreach ($recents as $recent) {
801            $changes[] = new MediaRevision([
802                'id' => $recent['id'],
803                'revision' => $recent['date'],
804                'author' => $recent['user'],
805                'ip' => $recent['ip'],
806                'summary' => $recent['sum'],
807                'type' => $recent['type'],
808                'sizechange' => $recent['sizechange'],
809            ]);
810        }
811
812        return $changes;
813    }
814
815    /**
816     * Get a media file's content
817     *
818     * Returns the content of the given media file. When no revision is given, the current revision is returned.
819     *
820     * @link https://en.wikipedia.org/wiki/Base64
821     * @param string $media file id
822     * @param int $rev revision timestamp
823     * @return string Base64 encoded media file contents
824     * @throws AccessDeniedException no permission for media
825     * @throws RemoteException not exist
826     * @author Gina Haeussge <osd@foosel.net>
827     *
828     */
829    public function getMedia($media, $rev = '')
830    {
831        $media = cleanID($media);
832        if (auth_quickaclcheck($media) < AUTH_READ) {
833            throw new AccessDeniedException('You are not allowed to read this file', 211);
834        }
835
836        $file = mediaFN($media, $rev);
837        if (!@ file_exists($file)) {
838            throw new RemoteException('The requested file does not exist', 221);
839        }
840
841        $data = io_readFile($file, false);
842        return base64_encode($data);
843    }
844
845    /**
846     * Return info about a media file
847     *
848     * The call will return an error if the requested media file does not exist.
849     *
850     * Read access is required for the media file.
851     *
852     * @param string $media file id
853     * @param int $rev revision timestamp
854     * @param bool $hash whether to include the MD5 hash of the media content
855     * @return Media
856     * @throws AccessDeniedException no permission for media
857     * @throws RemoteException if not exist
858     * @author Gina Haeussge <osd@foosel.net>
859     */
860    public function getMediaInfo($media, $rev = '', $hash = false)
861    {
862        $media = cleanID($media);
863        if (auth_quickaclcheck($media) < AUTH_READ) {
864            throw new AccessDeniedException('You are not allowed to read this file', 211);
865        }
866        if (!media_exists($media, $rev)) {
867            throw new RemoteException('The requested media file does not exist', 221);
868        }
869
870        $file = mediaFN($media, $rev);
871
872        $info = new Media([
873            'id' => $media,
874            'mtime' => filemtime($file),
875            'size' => filesize($file),
876        ]);
877        if ($hash) $info->calculateHash();
878
879        return $info;
880    }
881
882    /**
883     * Uploads a file to the wiki
884     *
885     * The file data has to be passed as a base64 encoded string.
886     *
887     * @link https://en.wikipedia.org/wiki/Base64
888     * @param string $media media id
889     * @param string $base64 Base64 encoded file contents
890     * @param bool $overwrite Should an existing file be overwritten?
891     * @return bool Should always be true
892     * @throws RemoteException
893     * @author Michael Klier <chi@chimeric.de>
894     */
895    public function saveMedia($media, $base64, $overwrite = false)
896    {
897        $media = cleanID($media);
898        $auth = auth_quickaclcheck(getNS($media) . ':*');
899
900        if ($media === '') {
901            throw new RemoteException('Media ID not given.', 231);
902        }
903
904        // clean up base64 encoded data
905        $base64 = strtr($base64, [
906            "\n" => '', // strip newlines
907            "\r" => '', // strip carriage returns
908            '-' => '+', // RFC4648 base64url
909            '_' => '/', // RFC4648 base64url
910            ' ' => '+', // JavaScript data uri
911        ]);
912
913        $data = base64_decode($base64, true);
914        if ($data === false) {
915            throw new RemoteException('Invalid base64 encoded data.', 231); // FIXME adjust code
916        }
917
918        // save temporary file
919        global $conf;
920        $ftmp = $conf['tmpdir'] . '/' . md5($media . clientIP());
921        @unlink($ftmp);
922        io_saveFile($ftmp, $data);
923
924        $res = media_save(['name' => $ftmp], $media, $overwrite, $auth, 'rename');
925        if (is_array($res)) {
926            throw new RemoteException($res[0], -$res[1]); // FIXME adjust code -1 * -1 = 1, we want a 23x code
927        }
928        return (bool)$res; // should always be true at this point
929    }
930
931    /**
932     * Deletes a file from the wiki
933     *
934     * You need to have delete permissions for the file.
935     *
936     * @param string $media media id
937     * @return bool Should always be true
938     * @throws AccessDeniedException no permissions
939     * @throws RemoteException file in use or not deleted
940     * @author Gina Haeussge <osd@foosel.net>
941     *
942     */
943    public function deleteMedia($media)
944    {
945        $media = cleanID($media);
946        $auth = auth_quickaclcheck($media);
947        $res = media_delete($media, $auth);
948        if ($res & DOKU_MEDIA_DELETED) {
949            return true;
950        } elseif ($res & DOKU_MEDIA_NOT_AUTH) {
951            throw new AccessDeniedException('You don\'t have permissions to delete files.', 212);
952        } elseif ($res & DOKU_MEDIA_INUSE) {
953            throw new RemoteException('File is still referenced', 232);
954        } else {
955            throw new RemoteException('Could not delete file', 233);
956        }
957    }
958
959    // endregion
960
961
962    /**
963     * Convenience method for page checks
964     *
965     * This method will perform multiple tasks:
966     *
967     * - clean the given page id
968     * - disallow an empty page id
969     * - check if the page exists (unless disabled)
970     * - check if the user has the required access level (pass AUTH_NONE to skip)
971     *
972     * @param string $id page id
973     * @return string the cleaned page id
974     * @throws RemoteException
975     * @throws AccessDeniedException
976     */
977    private function checkPage($id, $existCheck = true, $minAccess = AUTH_READ)
978    {
979        $id = cleanID($id);
980        if ($id === '') {
981            throw new RemoteException('Empty or invalid page ID given', 131); // FIXME check code
982        }
983
984        if ($existCheck && !page_exists($id)) {
985            throw new RemoteException('The requested page does not exist', 121); // FIXME check code
986        }
987
988        if ($minAccess && auth_quickaclcheck($id) < $minAccess) {
989            throw new AccessDeniedException('You are not allowed to read this page', 111); // FIXME check code
990        }
991
992        return $id;
993    }
994}
995