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