xref: /dokuwiki/inc/Remote/ApiCore.php (revision 0caa81c70023f1e9557c4b6769a9ff200df17a19)
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     */
208    public function aclCheck($page, $user = '', $groups = [])
209    {
210        /** @var AuthPlugin $auth */
211        global $auth;
212
213        $page = $this->resolvePageId($page);
214        if ($user === '') {
215            return auth_quickaclcheck($page);
216        } else {
217            if ($groups === []) {
218                $userinfo = $auth->getUserData($user);
219                if ($userinfo === false) {
220                    $groups = [];
221                } else {
222                    $groups = $userinfo['grps'];
223                }
224            }
225            return auth_aclcheck($page, $user, $groups);
226        }
227    }
228
229    // endregion
230
231    // region pages
232
233    /**
234     * List all pages in the given namespace (and below)
235     *
236     * Setting the `depth` to `0` and the `namespace` to `""` will return all pages in the wiki.
237     *
238     * @param string $namespace The namespace to search. Empty string for root namespace
239     * @param int $depth How deep to search. 0 for all subnamespaces
240     * @param bool $hash Whether to include a MD5 hash of the page content
241     * @return Page[] A list of matching pages
242     */
243    public function listPages($namespace = '', $depth = 1, $hash = false)
244    {
245        global $conf;
246
247        $namespace = cleanID($namespace);
248
249        // shortcut for all pages
250        if ($namespace === '' && $depth === 0) {
251            return $this->getAllPages($hash);
252        }
253
254        // run our search iterator to get the pages
255        $dir = utf8_encodeFN(str_replace(':', '/', $namespace));
256        $data = [];
257        $opts['skipacl'] = 0;
258        $opts['depth'] = $depth; // FIXME depth needs to be calculated relative to $dir
259        $opts['hash'] = $hash;
260        search($data, $conf['datadir'], 'search_allpages', $opts, $dir);
261
262        return array_map(fn($item) => new Page($item), $data);
263    }
264
265    /**
266     * Get all pages at once
267     *
268     * This is uses the page index and is quicker than iterating which is done in listPages()
269     *
270     * @return Page[] A list of all pages
271     * @see listPages()
272     */
273    protected function getAllPages($hash = false)
274    {
275        $list = [];
276        $pages = idx_get_indexer()->getPages();
277        Sort::ksort($pages);
278
279        foreach (array_keys($pages) as $idx) {
280            $perm = auth_quickaclcheck($pages[$idx]);
281            if ($perm < AUTH_READ || isHiddenPage($pages[$idx]) || !page_exists($pages[$idx])) {
282                continue;
283            }
284
285            $page = new Page([
286                'id' => $pages[$idx],
287                'perm' => $perm,
288            ]);
289            if ($hash) $page->calculateHash();
290
291            $list[] = $page;
292        }
293
294        return $list;
295    }
296
297    /**
298     * Do a fulltext search
299     *
300     * This executes a full text search and returns the results. The query uses the standard
301     * DokuWiki search syntax.
302     *
303     * Snippets are provided for the first 15 results only. The title is either the first heading
304     * or the page id depending on the wiki's configuration.
305     *
306     * @link https://www.dokuwiki.org/search#syntax
307     * @param string $query The search query as supported by the DokuWiki search
308     * @return PageHit[] A list of matching pages
309     */
310    public function searchPages($query)
311    {
312        $regex = [];
313        $data = ft_pageSearch($query, $regex);
314        $pages = [];
315
316        // prepare additional data
317        $idx = 0;
318        foreach ($data as $id => $score) {
319            $file = wikiFN($id);
320
321            if ($idx < FT_SNIPPET_NUMBER) {
322                $snippet = ft_snippet($id, $regex);
323                $idx++;
324            } else {
325                $snippet = '';
326            }
327
328            $pages[] = new PageHit([
329                'id' => $id,
330                'score' => (int)$score,
331                'rev' => filemtime($file),
332                'mtime' => filemtime($file),
333                'size' => filesize($file),
334                'snippet' => $snippet,
335                'title' => useHeading('navigation') ? p_get_first_heading($id) : $id
336            ]);
337        }
338        return $pages;
339    }
340
341    /**
342     * Get recent page changes
343     *
344     * Returns a list of recent changes to wiki pages. The results can be limited to changes newer than
345     * a given timestamp.
346     *
347     * Only changes within the configured `$conf['recent']` range are returned. This is the default
348     * when no timestamp is given.
349     *
350     * @link https://www.dokuwiki.org/config:recent
351     * @param int $timestamp Only show changes newer than this unix timestamp
352     * @return PageRevision[]
353     * @author Michael Klier <chi@chimeric.de>
354     * @author Michael Hamann <michael@content-space.de>
355     */
356    public function getRecentPageChanges($timestamp = 0)
357    {
358        $recents = getRecentsSince($timestamp);
359
360        $changes = [];
361        foreach ($recents as $recent) {
362            $changes[] = new PageRevision([
363                'id' => $recent['id'],
364                'revision' => $recent['date'],
365                'author' => $recent['user'],
366                'ip' => $recent['ip'],
367                'summary' => $recent['sum'],
368                'type' => $recent['type'],
369                'sizechange' => $recent['sizechange'],
370            ]);
371        }
372
373        return $changes;
374    }
375
376    /**
377     * Get a wiki page's syntax
378     *
379     * Returns the syntax of the given page. When no revision is given, the current revision is returned.
380     *
381     * A non-existing page (or revision) will return an empty string usually. For the current revision
382     * a page template will be returned if configured.
383     *
384     * Read access is required for the page.
385     *
386     * @param string $page wiki page id
387     * @param int $rev Revision timestamp to access an older revision
388     * @return string the syntax of the page
389     * @throws AccessDeniedException if no permission for page
390     */
391    public function getPage($page, $rev = '')
392    {
393        $page = $this->resolvePageId($page);
394        if (auth_quickaclcheck($page) < AUTH_READ) {
395            throw new AccessDeniedException('You are not allowed to read this file', 111);
396        }
397        $text = rawWiki($page, $rev);
398        if (!$text && !$rev) {
399            return pageTemplate($page);
400        } else {
401            return $text;
402        }
403    }
404
405    /**
406     * Return a wiki page rendered to HTML
407     *
408     * The page is rendered to HTML as it would be in the wiki. The HTML consist only of the data for the page
409     * content itself, no surrounding structural tags, header, footers, sidebars etc are returned.
410     *
411     * References in the HTML are relative to the wiki base URL unless the `canonical` configuration is set.
412     *
413     * If the page does not exist, an empty string is returned.
414     *
415     * @link https://www.dokuwiki.org/config:canonical
416     * @param string $page page id
417     * @param int $rev revision timestamp
418     * @return string Rendered HTML for the page
419     * @throws AccessDeniedException no access to page
420     */
421    public function getPageHTML($page, $rev = '')
422    {
423        $page = $this->resolvePageId($page);
424        if (auth_quickaclcheck($page) < AUTH_READ) {
425            throw new AccessDeniedException('You are not allowed to read this page', 111);
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 int $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 no access for page
443     * @throws RemoteException page not exist
444     */
445    public function getPageInfo($page, $rev = '', $author = false, $hash = false)
446    {
447        $page = $this->resolvePageId($page);
448        if (auth_quickaclcheck($page) < AUTH_READ) {
449            throw new AccessDeniedException('You are not allowed to read this page', 111);
450        }
451        if (!page_exists($page)) {
452            throw new RemoteException('The requested page does not exist', 121);
453        }
454
455        $result = new Page(['id' => $page, 'rev' => $rev]);
456        if ($author) $result->retrieveAuthor();
457        if ($hash) $result->calculateHash();
458
459        return $result;
460    }
461
462    /**
463     * Returns a list of available revisions of a given wiki page
464     *
465     * The number of returned pages is set by `$conf['recent']`, but non accessible revisions pages
466     * are skipped, so less than that may be returned.
467     *
468     * @link https://www.dokuwiki.org/config:recent
469     * @param string $page page id
470     * @param int $first skip the first n changelog lines, 0 starts at the current revision
471     * @return PageRevision[]
472     * @throws AccessDeniedException no read access for page
473     * @throws RemoteException empty id
474     * @author Michael Klier <chi@chimeric.de>
475     */
476    public function getPageVersions($page, $first = 0)
477    {
478        $page = $this->resolvePageId($page);
479        if (auth_quickaclcheck($page) < AUTH_READ) {
480            throw new AccessDeniedException('You are not allowed to read this page', 111);
481        }
482        global $conf;
483
484        if (empty($page)) {
485            throw new RemoteException('Empty page ID', 131);
486        }
487
488        $pagelog = new PageChangeLog($page);
489        $pagelog->setChunkSize(1024);
490        // old revisions are counted from 0, so we need to subtract 1 for the current one
491        $revisions = $pagelog->getRevisions($first - 1, $conf['recent']);
492
493        $result = [];
494        foreach ($revisions as $rev) {
495            if (!page_exists($page, $rev)) continue; // skip non-existing revisions
496            $info = $pagelog->getRevisionInfo($rev);
497
498            $result[] = new PageRevision([
499                'id' => $page,
500                'revision' => $rev,
501                'author' => $info['user'],
502                'ip' => $info['ip'],
503                'summary' => $info['sum'],
504                'type' => $info['type'],
505                'sizechange' => $info['sizechange'],
506            ]);
507        }
508
509        return $result;
510    }
511
512    /**
513     * Get a page's links
514     *
515     * This returns a list of links found in the given page. This includes internal, external and interwiki links
516     *
517     * Read access for the given page is needed
518     *
519     * @param string $page page id
520     * @return Link[] A list of links found on the given page
521     * @throws AccessDeniedException  no read access for page
522     * @author Michael Klier <chi@chimeric.de>
523     * @todo returning link titles would be a nice addition
524     * @todo hash handling seems not to be correct
525     */
526    public function getPageLinks($page)
527    {
528        $page = $this->resolvePageId($page);
529        if (auth_quickaclcheck($page) < AUTH_READ) {
530            throw new AccessDeniedException('You are not allowed to read this page', 111);
531        }
532
533        // resolve page instructions
534        $ins = p_cached_instructions(wikiFN($page));
535
536        // instantiate new Renderer - needed for interwiki links
537        $Renderer = new Doku_Renderer_xhtml();
538        $Renderer->interwiki = getInterwiki();
539
540        // parse instructions
541        $links = [];
542        foreach ($ins as $in) {
543            switch ($in[0]) {
544                case 'internallink':
545                    $links[] = new Link([
546                        'type' => 'local',
547                        'page' => $in[1][0],
548                        'href' => wl($in[1][0]),
549                    ]);
550                    break;
551                case 'externallink':
552                    $links[] = new Link([
553                        'type' => 'extern',
554                        'page' => $in[1][0],
555                        'href' => $in[1][0],
556                    ]);
557                    break;
558                case 'interwikilink':
559                    $url = $Renderer->_resolveInterWiki($in[1][2], $in[1][3]);
560                    $links[] = new Link([
561                        'type' => 'interwiki',
562                        'page' => $in[1][0],
563                        'href' => $url,
564                    ]);
565                    break;
566            }
567        }
568
569        return ($links);
570    }
571
572    /**
573     * Get a page's backlinks
574     *
575     * A backlink is a wiki link on another page that links to the given page.
576     *
577     * Only links from pages readable by the current user are returned.
578     *
579     * @param string $page page id
580     * @return string[] A list of pages linking to the given page
581     */
582    public function getPageBackLinks($page)
583    {
584        return ft_backlinks($this->resolvePageId($page));
585    }
586
587    /**
588     * Lock the given set of pages
589     *
590     * This call will try to lock all given pages. It will return a list of pages that were
591     * successfully locked. If a page could not be locked, eg. because a different user is
592     * currently holding a lock, that page will be missing from the returned list.
593     *
594     * You should always ensure that the list of returned pages matches the given list of
595     * pages. It's up to you to decide how to handle failed locking.
596     *
597     * Note: you can only lock pages that you have write access for. It is possible to create
598     * a lock for a page that does not exist, yet.
599     *
600     * Note: it is not necessary to lock a page before saving it. The `savePage()` call will
601     * automatically lock and unlock the page for you. However if you plan to do related
602     * operations on multiple pages, locking them all at once beforehand can be useful.
603     *
604     * @param string[] $pages A list of pages to lock
605     * @return string[] A list of pages that were successfully locked
606     */
607    public function lockPages($pages)
608    {
609        $locked = [];
610
611        foreach ($pages as $id) {
612            $id = $this->resolvePageId($id);
613            if ($id === '') continue;
614            if (auth_quickaclcheck($id) < AUTH_EDIT || checklock($id)) {
615                continue;
616            }
617            lock($id);
618            $locked[] = $id;
619        }
620        return $locked;
621    }
622
623    /**
624     * Unlock the given set of pages
625     *
626     * This call will try to unlock all given pages. It will return a list of pages that were
627     * successfully unlocked. If a page could not be unlocked, eg. because a different user is
628     * currently holding a lock, that page will be missing from the returned list.
629     *
630     * You should always ensure that the list of returned pages matches the given list of
631     * pages. It's up to you to decide how to handle failed unlocking.
632     *
633     * Note: you can only unlock pages that you have write access for.
634     *
635     * @param string[] $pages A list of pages to unlock
636     * @return string[] A list of pages that were successfully unlocked
637     */
638    public function unlockPages($pages)
639    {
640        $unlocked = [];
641
642        foreach ($pages as $id) {
643            $id = $this->resolvePageId($id);
644            if ($id === '') continue;
645            if (auth_quickaclcheck($id) < AUTH_EDIT || !unlock($id)) {
646                continue;
647            }
648            $unlocked[] = $id;
649        }
650
651        return $unlocked;
652    }
653
654    /**
655     * Save a wiki page
656     *
657     * Saves the given wiki text to the given page. If the page does not exist, it will be created.
658     * Just like in the wiki, saving an empty text will delete the page.
659     *
660     * You need write permissions for the given page and the page may not be locked by another user.
661     *
662     * @param string $page page id
663     * @param string $text wiki text
664     * @param string $summary edit summary
665     * @param bool $isminor whether this is a minor edit
666     * @return bool Returns true on success
667     * @throws AccessDeniedException no write access for page
668     * @throws RemoteException no id, empty new page or locked
669     * @author Michael Klier <chi@chimeric.de>
670     */
671    public function savePage($page, $text, $summary = '', $isminor = false)
672    {
673        global $TEXT;
674        global $lang;
675
676        $page = $this->resolvePageId($page);
677        $TEXT = cleanText($text);
678
679        if (empty($page)) {
680            throw new RemoteException('Empty page ID', 131);
681        }
682
683        if (!page_exists($page) && trim($TEXT) == '') {
684            throw new RemoteException('Refusing to write an empty new wiki page', 132);
685        }
686
687        if (auth_quickaclcheck($page) < AUTH_EDIT) {
688            throw new AccessDeniedException('You are not allowed to edit this page', 112);
689        }
690
691        // Check, if page is locked
692        if (checklock($page)) {
693            throw new RemoteException('The page is currently locked', 133);
694        }
695
696        // SPAM check
697        if (checkwordblock()) {
698            throw new RemoteException('Positive wordblock check', 134);
699        }
700
701        // autoset summary on new pages
702        if (!page_exists($page) && empty($summary)) {
703            $summary = $lang['created'];
704        }
705
706        // autoset summary on deleted pages
707        if (page_exists($page) && empty($TEXT) && empty($summary)) {
708            $summary = $lang['deleted'];
709        }
710
711        lock($page);
712        saveWikiText($page, $TEXT, $summary, $isminor);
713        unlock($page);
714
715        // run the indexer if page wasn't indexed yet
716        idx_addPage($page);
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)
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     * @throws AccessDeniedException no access to the media files
768     * @author Gina Haeussge <osd@foosel.net>
769     */
770    public function listMedia($namespace = '', $pattern = '', $depth = 1, $hash = false)
771    {
772        global $conf;
773
774        $namespace = cleanID($namespace);
775
776        if (auth_quickaclcheck($namespace . ':*') < AUTH_READ) {
777            throw new AccessDeniedException('You are not allowed to list media files.', 215);
778        }
779
780        $options = [
781            'skipacl' => 0,
782            'depth' => $depth,
783            'hash' => $hash,
784            'pattern' => $pattern,
785        ];
786
787        $dir = utf8_encodeFN(str_replace(':', '/', $namespace));
788        $data = [];
789        search($data, $conf['mediadir'], 'search_media', $options, $dir);
790        return array_map(fn($item) => new Media($item), $data);
791    }
792
793    /**
794     * Get recent media changes
795     *
796     * Returns a list of recent changes to media files. The results can be limited to changes newer than
797     * a given timestamp.
798     *
799     * Only changes within the configured `$conf['recent']` range are returned. This is the default
800     * when no timestamp is given.
801     *
802     * @link https://www.dokuwiki.org/config:recent
803     * @param int $timestamp Only show changes newer than this unix timestamp
804     * @return MediaRevision[]
805     * @author Michael Klier <chi@chimeric.de>
806     * @author Michael Hamann <michael@content-space.de>
807     */
808    public function getRecentMediaChanges($timestamp = 0)
809    {
810
811        $recents = getRecentsSince($timestamp, null, '', RECENTS_MEDIA_CHANGES);
812
813        $changes = [];
814        foreach ($recents as $recent) {
815            $changes[] = new MediaRevision([
816                'id' => $recent['id'],
817                'revision' => $recent['date'],
818                'author' => $recent['user'],
819                'ip' => $recent['ip'],
820                'summary' => $recent['sum'],
821                'type' => $recent['type'],
822                'sizechange' => $recent['sizechange'],
823            ]);
824        }
825
826        return $changes;
827    }
828
829    /**
830     * Get a media file's content
831     *
832     * Returns the content of the given media file. When no revision is given, the current revision is returned.
833     *
834     * @link https://en.wikipedia.org/wiki/Base64
835     * @param string $media file id
836     * @param int $rev revision timestamp
837     * @return string Base64 encoded media file contents
838     * @throws AccessDeniedException no permission for media
839     * @throws RemoteException not exist
840     * @author Gina Haeussge <osd@foosel.net>
841     *
842     */
843    public function getMedia($media, $rev = '')
844    {
845        $media = cleanID($media);
846        if (auth_quickaclcheck($media) < AUTH_READ) {
847            throw new AccessDeniedException('You are not allowed to read this file', 211);
848        }
849
850        $file = mediaFN($media, $rev);
851        if (!@ file_exists($file)) {
852            throw new RemoteException('The requested file does not exist', 221);
853        }
854
855        $data = io_readFile($file, false);
856        return base64_encode($data);
857    }
858
859    /**
860     * Return info about a media file
861     *
862     * The call will return an error if the requested media file does not exist.
863     *
864     * Read access is required for the media file.
865     *
866     * @param string $media file id
867     * @param int $rev revision timestamp
868     * @param bool $hash whether to include the MD5 hash of the media content
869     * @return Media
870     * @throws AccessDeniedException no permission for media
871     * @throws RemoteException if not exist
872     * @author Gina Haeussge <osd@foosel.net>
873     */
874    public function getMediaInfo($media, $rev = '', $hash = false)
875    {
876        $media = cleanID($media);
877        if (auth_quickaclcheck($media) < AUTH_READ) {
878            throw new AccessDeniedException('You are not allowed to read this file', 211);
879        }
880        if (!media_exists($media, $rev)) {
881            throw new RemoteException('The requested media file does not exist', 221);
882        }
883
884        $file = mediaFN($media, $rev);
885
886        $info = new Media([
887            'id' => $media,
888            'mtime' => filemtime($file),
889            'size' => filesize($file),
890        ]);
891        if ($hash) $info->calculateHash();
892
893        return $info;
894    }
895
896    /**
897     * Uploads a file to the wiki
898     *
899     * The file data has to be passed as a base64 encoded string.
900     *
901     * @link https://en.wikipedia.org/wiki/Base64
902     * @param string $media media id
903     * @param string $base64 Base64 encoded file contents
904     * @param bool $overwrite Should an existing file be overwritten?
905     * @return bool Should always be true
906     * @throws RemoteException
907     * @author Michael Klier <chi@chimeric.de>
908     */
909    public function saveMedia($media, $base64, $overwrite = false)
910    {
911        $media = cleanID($media);
912        $auth = auth_quickaclcheck(getNS($media) . ':*');
913
914        if ($media === '') {
915            throw new RemoteException('Media ID not given.', 231);
916        }
917
918        // clean up base64 encoded data
919        $base64 = strtr($base64, [
920            "\n" => '', // strip newlines
921            "\r" => '', // strip carriage returns
922            '-' => '+', // RFC4648 base64url
923            '_' => '/', // RFC4648 base64url
924            ' ' => '+', // JavaScript data uri
925        ]);
926
927        $data = base64_decode($base64, true);
928        if ($data === false) {
929            throw new RemoteException('Invalid base64 encoded data.', 231); // FIXME adjust code
930        }
931
932        // save temporary file
933        global $conf;
934        $ftmp = $conf['tmpdir'] . '/' . md5($media . clientIP());
935        @unlink($ftmp);
936        io_saveFile($ftmp, $data);
937
938        $res = media_save(['name' => $ftmp], $media, $overwrite, $auth, 'rename');
939        if (is_array($res)) {
940            throw new RemoteException($res[0], -$res[1]); // FIXME adjust code -1 * -1 = 1, we want a 23x code
941        }
942        return (bool)$res; // should always be true at this point
943    }
944
945    /**
946     * Deletes a file from the wiki
947     *
948     * You need to have delete permissions for the file.
949     *
950     * @param string $media media id
951     * @return bool Should always be true
952     * @throws AccessDeniedException no permissions
953     * @throws RemoteException file in use or not deleted
954     * @author Gina Haeussge <osd@foosel.net>
955     *
956     */
957    public function deleteMedia($media)
958    {
959        $media = cleanID($media);
960        $auth = auth_quickaclcheck($media);
961        $res = media_delete($media, $auth);
962        if ($res & DOKU_MEDIA_DELETED) {
963            return true;
964        } elseif ($res & DOKU_MEDIA_NOT_AUTH) {
965            throw new AccessDeniedException('You don\'t have permissions to delete files.', 212);
966        } elseif ($res & DOKU_MEDIA_INUSE) {
967            throw new RemoteException('File is still referenced', 232);
968        } else {
969            throw new RemoteException('Could not delete file', 233);
970        }
971    }
972
973    // endregion
974
975
976    /**
977     * Resolve page id
978     *
979     * @param string $id page id
980     * @return string
981     */
982    private function resolvePageId($id)
983    {
984        $id = cleanID($id);
985        if (empty($id)) {
986            global $conf;
987            $id = cleanID($conf['start']); // FIXME I dont think we want that!
988        }
989        return $id;
990    }
991}
992