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