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