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