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