xref: /dokuwiki/inc/media.php (revision dd9e8e5ea54469964faab99223a61bd48146ac42)
1<?php
2
3/**
4 * All output and handler function needed for the media management popup
5 *
6 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
7 * @author     Andreas Gohr <andi@splitbrain.org>
8 */
9
10use dokuwiki\Ui\MediaRevisions;
11use dokuwiki\Cache\CacheImageMod;
12use splitbrain\slika\Exception;
13use dokuwiki\PassHash;
14use dokuwiki\ChangeLog\MediaChangeLog;
15use dokuwiki\Extension\Event;
16use dokuwiki\File\MediaFile;
17use dokuwiki\Form\Form;
18use dokuwiki\HTTP\DokuHTTPClient;
19use dokuwiki\Logger;
20use dokuwiki\Subscriptions\MediaSubscriptionSender;
21use dokuwiki\Ui\Media\Display;
22use dokuwiki\Ui\Media\DisplayRow;
23use dokuwiki\Ui\Media\DisplayTile;
24use dokuwiki\Ui\MediaDiff;
25use dokuwiki\Utf8\PhpString;
26use dokuwiki\Utf8\Sort;
27use splitbrain\slika\Slika;
28
29/**
30 * Lists pages which currently use a media file selected for deletion
31 *
32 * References uses the same visual as search results and share
33 * their CSS tags except pagenames won't be links.
34 *
35 * @author Matthias Grimm <matthiasgrimm@users.sourceforge.net>
36 *
37 * @param array $data
38 * @param string $id
39 */
40function media_filesinuse($data, $id)
41{
42    global $lang;
43    echo '<h1>' . $lang['reference'] . ' <code>' . hsc(noNS($id)) . '</code></h1>';
44    echo '<p>' . hsc($lang['ref_inuse']) . '</p>';
45
46    $hidden = 0; //count of hits without read permission
47    foreach ($data as $row) {
48        if (auth_quickaclcheck($row) >= AUTH_READ && isVisiblePage($row)) {
49            echo '<div class="search_result">';
50            echo '<span class="mediaref_ref">' . hsc($row) . '</span>';
51            echo '</div>';
52        } else $hidden++;
53    }
54    if ($hidden) {
55        echo '<div class="mediaref_hidden">' . $lang['ref_hidden'] . '</div>';
56    }
57}
58
59/**
60 * Handles the saving of image meta data
61 *
62 * @author Andreas Gohr <andi@splitbrain.org>
63 * @author Kate Arzamastseva <pshns@ukr.net>
64 *
65 * @param string $id media id
66 * @param int $auth permission level
67 * @param array $data
68 * @return false|string
69 */
70function media_metasave($id, $auth, $data)
71{
72    if ($auth < AUTH_UPLOAD) return false;
73    if (!checkSecurityToken()) return false;
74    global $lang;
75    global $conf;
76    $src = mediaFN($id);
77
78    $meta = new JpegMeta($src);
79    $meta->_parseAll();
80
81    foreach ($data as $key => $val) {
82        $val = trim($val);
83        if (empty($val)) {
84            $meta->deleteField($key);
85        } else {
86            $meta->setField($key, $val);
87        }
88    }
89
90    $old = @filemtime($src);
91    if (!file_exists(mediaFN($id, $old)) && file_exists($src)) {
92        // add old revision to the attic
93        media_saveOldRevision($id);
94    }
95    $filesize_old = filesize($src);
96    if ($meta->save()) {
97        if ($conf['fperm']) chmod($src, $conf['fperm']);
98        @clearstatcache(true, $src);
99        $new = @filemtime($src);
100        $filesize_new = filesize($src);
101        $sizechange = $filesize_new - $filesize_old;
102
103        // add a log entry to the media changelog
104        addMediaLogEntry($new, $id, DOKU_CHANGE_TYPE_EDIT, $lang['media_meta_edited'], '', null, $sizechange);
105
106        msg($lang['metasaveok'], 1);
107        return $id;
108    } else {
109        msg($lang['metasaveerr'], -1);
110        return false;
111    }
112}
113
114/**
115 * check if a media is external source
116 *
117 * @author Gerrit Uitslag <klapinklapin@gmail.com>
118 *
119 * @param string $id the media ID or URL
120 * @return bool
121 */
122function media_isexternal($id)
123{
124    if (preg_match('#^(?:https?|ftp)://#i', $id)) return true;
125    return false;
126}
127
128/**
129 * Check if a media item is public (eg, external URL or readable by @ALL)
130 *
131 * @author Andreas Gohr <andi@splitbrain.org>
132 *
133 * @param string $id  the media ID or URL
134 * @return bool
135 */
136function media_ispublic($id)
137{
138    if (media_isexternal($id)) return true;
139    $id = cleanID($id);
140    if (auth_aclcheck(getNS($id) . ':*', '', []) >= AUTH_READ) return true;
141    return false;
142}
143
144/**
145 * Display the form to edit image meta data
146 *
147 * @author Andreas Gohr <andi@splitbrain.org>
148 * @author Kate Arzamastseva <pshns@ukr.net>
149 *
150 * @param string $id media id
151 * @param int $auth permission level
152 * @return bool
153 */
154function media_metaform($id, $auth)
155{
156    global $lang;
157
158    if ($auth < AUTH_UPLOAD) {
159        echo '<div class="nothing">' . $lang['media_perm_upload'] . '</div>' . DOKU_LF;
160        return false;
161    }
162
163    // load the field descriptions
164    static $fields = null;
165    if ($fields === null) {
166        $config_files = getConfigFiles('mediameta');
167        foreach ($config_files as $config_file) {
168            if (file_exists($config_file)) include($config_file);
169        }
170    }
171
172    $src = mediaFN($id);
173
174    // output
175    $form = new Form([
176            'action' => media_managerURL(['tab_details' => 'view'], '&'),
177            'class' => 'meta'
178    ]);
179    $form->addTagOpen('div')->addClass('no');
180    $form->setHiddenField('img', $id);
181    $form->setHiddenField('mediado', 'save');
182    foreach ($fields as $key => $field) {
183        // get current value
184        if (empty($field[0])) continue;
185        $tags = [$field[0]];
186        if (isset($field[3]) && is_array($field[3])) $tags = array_merge($tags, $field[3]);
187        $value = tpl_img_getTag($tags, '', $src);
188        $value = cleanText($value);
189
190        // prepare attributes
191        $p = [
192            'class' => 'edit',
193            'id'    => 'meta__' . $key,
194            'name'  => 'meta[' . $field[0] . ']'
195        ];
196
197        $form->addTagOpen('div')->addClass('row');
198        if ($field[2] == 'text') {
199            $form->addTextInput(
200                $p['name'],
201                ($lang[$field[1]] ?: $field[1] . ':')
202            )->id($p['id'])->addClass($p['class'])->val($value);
203        } else {
204            $form->addTextarea($p['name'], $lang[$field[1]])->id($p['id'])
205                ->val(formText($value))
206                ->addClass($p['class'])
207                ->attr('rows', '6')->attr('cols', '50');
208        }
209        $form->addTagClose('div');
210    }
211    $form->addTagOpen('div')->addClass('buttons');
212    $form->addButton('mediado[save]', $lang['btn_save'])->attr('type', 'submit')
213        ->attrs(['accesskey' => 's']);
214    $form->addTagClose('div');
215
216    $form->addTagClose('div');
217    echo $form->toHTML();
218    return true;
219}
220
221/**
222 * Convenience function to check if a media file is still in use
223 *
224 * @author Michael Klier <chi@chimeric.de>
225 *
226 * @param string $id media id
227 * @return array|bool
228 */
229function media_inuse($id)
230{
231    global $conf;
232
233    if ($conf['refcheck']) {
234        $mediareferences = ft_mediause($id, true);
235        if ($mediareferences === []) {
236            return false;
237        } else {
238            return $mediareferences;
239        }
240    } else {
241        return false;
242    }
243}
244
245/**
246 * Handles media file deletions
247 *
248 * If configured, checks for media references before deletion
249 *
250 * @author             Andreas Gohr <andi@splitbrain.org>
251 *
252 * @param string $id media id
253 * @param int $auth no longer used
254 * @return int One of: 0,
255 *                     DOKU_MEDIA_DELETED,
256 *                     DOKU_MEDIA_DELETED | DOKU_MEDIA_EMPTY_NS,
257 *                     DOKU_MEDIA_NOT_AUTH,
258 *                     DOKU_MEDIA_INUSE
259 */
260function media_delete($id, $auth)
261{
262    global $lang;
263    $auth = auth_quickaclcheck(ltrim(getNS($id) . ':*', ':'));
264    if ($auth < AUTH_DELETE) return DOKU_MEDIA_NOT_AUTH;
265    if (media_inuse($id)) return DOKU_MEDIA_INUSE;
266
267    $file = mediaFN($id);
268
269    // trigger an event - MEDIA_DELETE_FILE
270    $data = [];
271    $data['id']   = $id;
272    $data['name'] = PhpString::basename($file);
273    $data['path'] = $file;
274    $data['size'] = (file_exists($file)) ? filesize($file) : 0;
275
276    $data['unl'] = false;
277    $data['del'] = false;
278    $evt = new Event('MEDIA_DELETE_FILE', $data);
279    if ($evt->advise_before()) {
280        $old = @filemtime($file);
281        if (!file_exists(mediaFN($id, $old)) && file_exists($file)) {
282            // add old revision to the attic
283            media_saveOldRevision($id);
284        }
285
286        $data['unl'] = @unlink($file);
287        if ($data['unl']) {
288            $sizechange = 0 - $data['size'];
289            addMediaLogEntry(time(), $id, DOKU_CHANGE_TYPE_DELETE, $lang['deleted'], '', null, $sizechange);
290
291            $data['del'] = io_sweepNS($id, 'mediadir');
292        }
293    }
294    $evt->advise_after();
295    unset($evt);
296
297    if ($data['unl'] && $data['del']) {
298        return DOKU_MEDIA_DELETED | DOKU_MEDIA_EMPTY_NS;
299    }
300
301    return $data['unl'] ? DOKU_MEDIA_DELETED : 0;
302}
303
304/**
305 * Handle file uploads via XMLHttpRequest
306 *
307 * @param string $ns   target namespace
308 * @param int    $auth current auth check result
309 * @return false|string false on error, id of the new file on success
310 */
311function media_upload_xhr($ns, $auth)
312{
313    if (!checkSecurityToken()) return false;
314    global $INPUT;
315
316    $id = $INPUT->get->str('qqfile');
317    [$ext, $mime] = mimetype($id);
318    $input = fopen("php://input", "r");
319    if (!($tmp = io_mktmpdir())) return false;
320    $path = $tmp . '/' . md5($id);
321    $target = fopen($path, "w");
322    $realSize = stream_copy_to_stream($input, $target);
323    fclose($target);
324    fclose($input);
325    if ($INPUT->server->has('CONTENT_LENGTH') && ($realSize != $INPUT->server->int('CONTENT_LENGTH'))) {
326        unlink($path);
327        return false;
328    }
329
330    $res = media_save(
331        ['name' => $path, 'mime' => $mime, 'ext'  => $ext],
332        $ns . ':' . $id,
333        ($INPUT->get->str('ow') == 'true'),
334        $auth,
335        'copy'
336    );
337    unlink($path);
338    if ($tmp) io_rmdir($tmp, true);
339    if (is_array($res)) {
340        msg($res[0], $res[1]);
341        return false;
342    }
343    return $res;
344}
345
346/**
347 * Handles media file uploads
348 *
349 * @author Andreas Gohr <andi@splitbrain.org>
350 * @author Michael Klier <chi@chimeric.de>
351 *
352 * @param string     $ns    target namespace
353 * @param int        $auth  current auth check result
354 * @param bool|array $file  $_FILES member, $_FILES['upload'] if false
355 * @return false|string false on error, id of the new file on success
356 */
357function media_upload($ns, $auth, $file = false)
358{
359    if (!checkSecurityToken()) return false;
360    global $lang;
361    global $INPUT;
362
363    // get file and id
364    $id   = $INPUT->post->str('mediaid');
365    if (!$file) $file = $_FILES['upload'];
366    if (empty($id)) $id = $file['name'];
367
368    // check for errors (messages are done in lib/exe/mediamanager.php)
369    if ($file['error']) return false;
370
371    // check extensions
372    [$fext, $fmime] = mimetype($file['name']);
373    [$iext, $imime] = mimetype($id);
374    if ($fext && !$iext) {
375        // no extension specified in id - read original one
376        $id   .= '.' . $fext;
377        $imime = $fmime;
378    } elseif ($fext && $fext != $iext) {
379        // extension was changed, print warning
380        msg(sprintf($lang['mediaextchange'], $fext, $iext));
381    }
382
383    $res = media_save(
384        [
385            'name' => $file['tmp_name'],
386            'mime' => $imime,
387            'ext' => $iext
388        ],
389        $ns . ':' . $id,
390        $INPUT->post->bool('ow'),
391        $auth,
392        'copy_uploaded_file'
393    );
394    if (is_array($res)) {
395        msg($res[0], $res[1]);
396        return false;
397    }
398    return $res;
399}
400
401/**
402 * An alternative to move_uploaded_file that copies
403 *
404 * Using copy, makes sure any setgid bits on the media directory are honored
405 *
406 * @see   move_uploaded_file()
407 *
408 * @param string $from
409 * @param string $to
410 * @return bool
411 */
412function copy_uploaded_file($from, $to)
413{
414    if (!is_uploaded_file($from)) return false;
415    $ok = copy($from, $to);
416    @unlink($from);
417    return $ok;
418}
419
420/**
421 * This generates an action event and delegates to _media_upload_action().
422 * Action plugins are allowed to pre/postprocess the uploaded file.
423 * (The triggered event is preventable.)
424 *
425 * Event data:
426 * $data[0]     fn_tmp:    the temporary file name (read from $_FILES)
427 * $data[1]     fn:        the file name of the uploaded file
428 * $data[2]     id:        the future directory id of the uploaded file
429 * $data[3]     imime:     the mimetype of the uploaded file
430 * $data[4]     overwrite: if an existing file is going to be overwritten
431 * $data[5]     move:      name of function that performs move/copy/..
432 *
433 * @triggers MEDIA_UPLOAD_FINISH
434 *
435 * @param array  $file
436 * @param string $id   media id
437 * @param bool   $ow   overwrite?
438 * @param int    $auth permission level
439 * @param string $move name of functions that performs move/copy/..
440 * @return false|array|string
441 */
442function media_save($file, $id, $ow, $auth, $move)
443{
444    if ($auth < AUTH_UPLOAD) {
445        return ["You don't have permissions to upload files.", -1];
446    }
447
448    if (!isset($file['mime']) || !isset($file['ext'])) {
449        [$ext, $mime] = mimetype($id);
450        if (!isset($file['mime'])) {
451            $file['mime'] = $mime;
452        }
453        if (!isset($file['ext'])) {
454            $file['ext'] = $ext;
455        }
456    }
457
458    global $lang, $conf;
459
460    // get filename
461    $id   = cleanID($id);
462    $fn   = mediaFN($id);
463
464    // get filetype regexp
465    $types = array_keys(getMimeTypes());
466    $types = array_map(
467        static fn($q) => preg_quote($q, "/"),
468        $types
469    );
470    $regex = implode('|', $types);
471
472    // because a temp file was created already
473    if (!preg_match('/\.(' . $regex . ')$/i', $fn)) {
474        return [$lang['uploadwrong'], -1];
475    }
476
477    //check for overwrite
478    $overwrite = file_exists($fn);
479    $auth_ow = (($conf['mediarevisions']) ? AUTH_UPLOAD : AUTH_DELETE);
480    if ($overwrite && (!$ow || $auth < $auth_ow)) {
481        return [$lang['uploadexist'], 0];
482    }
483    // check for valid content
484    $ok = media_contentcheck($file['name'], $file['mime']);
485    if ($ok == -1) {
486        return [sprintf($lang['uploadbadcontent'], '.' . $file['ext']), -1];
487    } elseif ($ok == -2) {
488        return [$lang['uploadspam'], -1];
489    } elseif ($ok == -3) {
490        return [$lang['uploadxss'], -1];
491    }
492
493    // prepare event data
494    $data = [];
495    $data[0] = $file['name'];
496    $data[1] = $fn;
497    $data[2] = $id;
498    $data[3] = $file['mime'];
499    $data[4] = $overwrite;
500    $data[5] = $move;
501
502    // trigger event
503    return Event::createAndTrigger('MEDIA_UPLOAD_FINISH', $data, '_media_upload_action', true);
504}
505
506/**
507 * Callback adapter for media_upload_finish() triggered by MEDIA_UPLOAD_FINISH
508 *
509 * @author Michael Klier <chi@chimeric.de>
510 *
511 * @param array $data event data
512 * @return false|array|string
513 */
514function _media_upload_action($data)
515{
516    // fixme do further sanity tests of given data?
517    if (is_array($data) && count($data) === 6) {
518        return media_upload_finish($data[0], $data[1], $data[2], $data[3], $data[4], $data[5]);
519    } else {
520        return false; //callback error
521    }
522}
523
524/**
525 * Saves an uploaded media file
526 *
527 * @author Andreas Gohr <andi@splitbrain.org>
528 * @author Michael Klier <chi@chimeric.de>
529 * @author Kate Arzamastseva <pshns@ukr.net>
530 *
531 * @param string $fn_tmp
532 * @param string $fn
533 * @param string $id        media id
534 * @param string $imime     mime type
535 * @param bool   $overwrite overwrite existing?
536 * @param string $move      function name
537 * @return array|string
538 */
539function media_upload_finish($fn_tmp, $fn, $id, $imime, $overwrite, $move = 'move_uploaded_file')
540{
541    global $conf;
542    global $lang;
543    global $REV;
544
545    $old = @filemtime($fn);
546    if (!file_exists(mediaFN($id, $old)) && file_exists($fn)) {
547        // add old revision to the attic if missing
548        media_saveOldRevision($id);
549    }
550
551    // prepare directory
552    io_createNamespace($id, 'media');
553
554    $filesize_old = file_exists($fn) ? filesize($fn) : 0;
555
556    if ($move($fn_tmp, $fn)) {
557        @clearstatcache(true, $fn);
558        $new = @filemtime($fn);
559        // Set the correct permission here.
560        // Always chmod media because they may be saved with different permissions than expected from the php umask.
561        // (Should normally chmod to $conf['fperm'] only if $conf['fperm'] is set.)
562        chmod($fn, $conf['fmode']);
563        msg($lang['uploadsucc'], 1);
564        media_notify($id, $fn, $imime, $old, $new);
565        // add a log entry to the media changelog
566        $filesize_new = filesize($fn);
567        $sizechange = $filesize_new - $filesize_old;
568        if ($REV) {
569            addMediaLogEntry(
570                $new,
571                $id,
572                DOKU_CHANGE_TYPE_REVERT,
573                sprintf($lang['restored'], dformat($REV)),
574                $REV,
575                null,
576                $sizechange
577            );
578        } elseif ($overwrite) {
579            addMediaLogEntry($new, $id, DOKU_CHANGE_TYPE_EDIT, '', '', null, $sizechange);
580        } else {
581            addMediaLogEntry($new, $id, DOKU_CHANGE_TYPE_CREATE, $lang['created'], '', null, $sizechange);
582        }
583        return $id;
584    } else {
585        return [$lang['uploadfail'], -1];
586    }
587}
588
589/**
590 * Moves the current version of media file to the media_attic
591 * directory
592 *
593 * @author Kate Arzamastseva <pshns@ukr.net>
594 *
595 * @param string $id
596 * @return int - revision date
597 */
598function media_saveOldRevision($id)
599{
600    global $conf, $lang;
601
602    $oldf = mediaFN($id);
603    if (!file_exists($oldf)) return '';
604    $date = filemtime($oldf);
605    if (!$conf['mediarevisions']) return $date;
606
607    $medialog = new MediaChangeLog($id);
608    if (!$medialog->getRevisionInfo($date)) {
609        // there was an external edit,
610        // there is no log entry for current version of file
611        $sizechange = filesize($oldf);
612        if (!file_exists(mediaMetaFN($id, '.changes'))) {
613            addMediaLogEntry($date, $id, DOKU_CHANGE_TYPE_CREATE, $lang['created'], '', null, $sizechange);
614        } else {
615            $oldRev = $medialog->getRevisions(-1, 1); // from changelog
616            $oldRev = (int) (empty($oldRev) ? 0 : $oldRev[0]);
617            $filesize_old = filesize(mediaFN($id, $oldRev));
618            $sizechange -= $filesize_old;
619
620            addMediaLogEntry($date, $id, DOKU_CHANGE_TYPE_EDIT, '', '', null, $sizechange);
621        }
622    }
623
624    $newf = mediaFN($id, $date);
625    io_makeFileDir($newf);
626    if (copy($oldf, $newf)) {
627        // Set the correct permission here.
628        // Always chmod media because they may be saved with different permissions than expected from the php umask.
629        // (Should normally chmod to $conf['fperm'] only if $conf['fperm'] is set.)
630        chmod($newf, $conf['fmode']);
631    }
632    return $date;
633}
634
635/**
636 * This function checks if the uploaded content is really what the
637 * mimetype says it is. We also do spam checking for text types here.
638 *
639 * We need to do this stuff because we can not rely on the browser
640 * to do this check correctly. Yes, IE is broken as usual.
641 *
642 * @author Andreas Gohr <andi@splitbrain.org>
643 * @link   http://www.splitbrain.org/blog/2007-02/12-internet_explorer_facilitates_cross_site_scripting
644 * @fixme  check all 26 magic IE filetypes here?
645 *
646 * @param string $file path to file
647 * @param string $mime mimetype
648 * @return int
649 */
650function media_contentcheck($file, $mime)
651{
652    global $conf;
653    if ($conf['iexssprotect']) {
654        $fh = @fopen($file, 'rb');
655        if ($fh) {
656            $bytes = fread($fh, 256);
657            fclose($fh);
658            if (preg_match('/<(script|a|img|html|body|iframe)[\s>]/i', $bytes)) {
659                return -3; //XSS: possibly malicious content
660            }
661        }
662    }
663    if (str_starts_with($mime, 'image/')) {
664        $info = @getimagesize($file);
665        if ($mime == 'image/gif' && $info[2] != 1) {
666            return -1; // uploaded content did not match the file extension
667        } elseif ($mime == 'image/jpeg' && $info[2] != 2) {
668            return -1;
669        } elseif ($mime == 'image/png' && $info[2] != 3) {
670            return -1;
671        }
672        # fixme maybe check other images types as well
673    } elseif (str_starts_with($mime, 'text/')) {
674        global $TEXT;
675        $TEXT = io_readFile($file);
676        if (checkwordblock()) {
677            return -2; //blocked by the spam blacklist
678        }
679    }
680    return 0;
681}
682
683/**
684 * Send a notify mail on uploads
685 *
686 * @author Andreas Gohr <andi@splitbrain.org>
687 *
688 * @param string   $id      media id
689 * @param string   $file    path to file
690 * @param string   $mime    mime type
691 * @param bool|int $old_rev revision timestamp or false
692 */
693function media_notify($id, $file, $mime, $old_rev = false, $current_rev = false)
694{
695    global $conf;
696    if (empty($conf['notify'])) return; //notify enabled?
697
698    $subscription = new MediaSubscriptionSender();
699    $subscription->sendMediaDiff($conf['notify'], 'uploadmail', $id, $old_rev, $current_rev);
700}
701
702/**
703 * List all files in a given Media namespace
704 *
705 * @param string      $ns             namespace
706 * @param null|int    $auth           permission level
707 * @param string      $jump           id
708 * @param bool        $fullscreenview
709 * @param bool|string $sort           sorting order, false skips sorting
710 */
711function media_filelist($ns, $auth = null, $jump = '', $fullscreenview = false, $sort = false)
712{
713    global $conf;
714    global $lang;
715    $ns = cleanID($ns);
716
717    // check auth our self if not given (needed for ajax calls)
718    if (is_null($auth)) $auth = auth_quickaclcheck("$ns:*");
719
720    if (!$fullscreenview) echo '<h1 id="media__ns">:' . hsc($ns) . '</h1>' . NL;
721
722    if ($auth < AUTH_READ) {
723        // FIXME: print permission warning here instead?
724        echo '<div class="nothing">' . $lang['nothingfound'] . '</div>' . NL;
725    } else {
726        if (!$fullscreenview) {
727            media_uploadform($ns, $auth);
728            media_searchform($ns);
729        }
730
731        $dir = utf8_encodeFN(str_replace(':', '/', $ns));
732        $data = [];
733        search(
734            $data,
735            $conf['mediadir'],
736            'search_mediafiles',
737            ['showmsg' => true, 'depth' => 1],
738            $dir,
739            1,
740            $sort
741        );
742
743        if ($data === []) {
744            echo '<div class="nothing">' . $lang['nothingfound'] . '</div>' . NL;
745        } else {
746            if ($fullscreenview) {
747                echo '<ul class="' . _media_get_list_type() . '">';
748            }
749            foreach ($data as $item) {
750                if (!$fullscreenview) {
751                    //FIXME old call: media_printfile($item,$auth,$jump);
752                    $display = new DisplayRow($item);
753                    $display->scrollIntoView($jump == $item->getID());
754                    $display->show();
755                } else {
756                    //FIXME old call: media_printfile_thumbs($item,$auth,$jump);
757                    echo '<li>';
758                    $display = new DisplayTile($item);
759                    $display->scrollIntoView($jump == $item->getID());
760                    $display->show();
761                    echo '</li>';
762                }
763            }
764            if ($fullscreenview) echo '</ul>' . NL;
765        }
766    }
767}
768
769/**
770 * Prints tabs for files list actions
771 *
772 * @author Kate Arzamastseva <pshns@ukr.net>
773 * @author Adrian Lang <mail@adrianlang.de>
774 *
775 * @param string $selected_tab - opened tab
776 */
777
778function media_tabs_files($selected_tab = '')
779{
780    global $lang;
781    $tabs = [];
782    foreach (
783        [
784            'files' => 'mediaselect',
785            'upload' => 'media_uploadtab',
786            'search' => 'media_searchtab'
787        ] as $tab => $caption
788    ) {
789        $tabs[$tab] = [
790            'href'    => media_managerURL(['tab_files' => $tab], '&'),
791            'caption' => $lang[$caption]
792        ];
793    }
794
795    html_tabs($tabs, $selected_tab);
796}
797
798/**
799 * Prints tabs for files details actions
800 *
801 * @author Kate Arzamastseva <pshns@ukr.net>
802 * @param string $image filename of the current image
803 * @param string $selected_tab opened tab
804 */
805function media_tabs_details($image, $selected_tab = '')
806{
807    global $lang, $conf;
808
809    $tabs = [];
810    $tabs['view'] = [
811        'href'    => media_managerURL(['tab_details' => 'view'], '&'),
812        'caption' => $lang['media_viewtab']
813    ];
814
815    [, $mime] = mimetype($image);
816    if ($mime == 'image/jpeg' && file_exists(mediaFN($image))) {
817        $tabs['edit'] = [
818            'href'    => media_managerURL(['tab_details' => 'edit'], '&'),
819            'caption' => $lang['media_edittab']
820        ];
821    }
822    if ($conf['mediarevisions']) {
823        $tabs['history'] = [
824            'href'    => media_managerURL(['tab_details' => 'history'], '&'),
825            'caption' => $lang['media_historytab']
826        ];
827    }
828
829    html_tabs($tabs, $selected_tab);
830}
831
832/**
833 * Prints options for the tab that displays a list of all files
834 *
835 * @author Kate Arzamastseva <pshns@ukr.net>
836 */
837function media_tab_files_options()
838{
839    global $lang;
840    global $INPUT;
841    global $ID;
842
843    $form = new Form([
844            'method' => 'get',
845            'action' => wl($ID),
846            'class' => 'options'
847    ]);
848    $form->addTagOpen('div')->addClass('no');
849    $form->setHiddenField('sectok', null);
850    $media_manager_params = media_managerURL([], '', false, true);
851    foreach ($media_manager_params as $pKey => $pVal) {
852        $form->setHiddenField($pKey, $pVal);
853    }
854    if ($INPUT->has('q')) {
855        $form->setHiddenField('q', $INPUT->str('q'));
856    }
857    $form->addHTML('<ul>' . NL);
858    foreach (
859        [
860            'list' => ['listType', ['thumbs', 'rows']],
861            'sort' => ['sortBy', ['name', 'date']]
862        ] as $group => $content
863    ) {
864        $checked = "_media_get_{$group}_type";
865        $checked = $checked();
866
867        $form->addHTML('<li class="' . $content[0] . '">');
868        foreach ($content[1] as $option) {
869            $attrs = [];
870            if ($checked == $option) {
871                $attrs['checked'] = 'checked';
872            }
873            $radio = $form->addRadioButton(
874                $group . '_dwmedia',
875                $lang['media_' . $group . '_' . $option]
876            )->val($option)->id($content[0] . '__' . $option)->addClass($option);
877            $radio->attrs($attrs);
878        }
879        $form->addHTML('</li>' . NL);
880    }
881    $form->addHTML('<li>');
882    $form->addButton('', $lang['btn_apply'])->attr('type', 'submit');
883    $form->addHTML('</li>' . NL);
884    $form->addHTML('</ul>' . NL);
885    $form->addTagClose('div');
886    echo $form->toHTML();
887}
888
889/**
890 * Returns type of sorting for the list of files in media manager
891 *
892 * @author Kate Arzamastseva <pshns@ukr.net>
893 *
894 * @return string - sort type
895 */
896function _media_get_sort_type()
897{
898    return _media_get_display_param('sort', ['default' => 'name', 'date']);
899}
900
901/**
902 * Returns type of listing for the list of files in media manager
903 *
904 * @author Kate Arzamastseva <pshns@ukr.net>
905 *
906 * @return string - list type
907 */
908function _media_get_list_type()
909{
910    return _media_get_display_param('list', ['default' => 'thumbs', 'rows']);
911}
912
913/**
914 * Get display parameters
915 *
916 * @param string $param   name of parameter
917 * @param array  $values  allowed values, where default value has index key 'default'
918 * @return string the parameter value
919 */
920function _media_get_display_param($param, $values)
921{
922    global $INPUT;
923    if (in_array($INPUT->str($param), $values)) {
924        // FIXME: Set cookie
925        return $INPUT->str($param);
926    } else {
927        $val = get_doku_pref($param, $values['default']);
928        if (!in_array($val, $values)) {
929            $val = $values['default'];
930        }
931        return $val;
932    }
933}
934
935/**
936 * Prints tab that displays a list of all files
937 *
938 * @author Kate Arzamastseva <pshns@ukr.net>
939 *
940 * @param string    $ns
941 * @param null|int  $auth permission level
942 * @param string    $jump item id
943 */
944function media_tab_files($ns, $auth = null, $jump = '')
945{
946    global $lang;
947    if (is_null($auth)) $auth = auth_quickaclcheck("$ns:*");
948
949    if ($auth < AUTH_READ) {
950        echo '<div class="nothing">' . $lang['media_perm_read'] . '</div>' . NL;
951    } else {
952        media_filelist($ns, $auth, $jump, true, _media_get_sort_type());
953    }
954}
955
956/**
957 * Prints tab that displays uploading form
958 *
959 * @author Kate Arzamastseva <pshns@ukr.net>
960 *
961 * @param string   $ns
962 * @param null|int $auth permission level
963 * @param string   $jump item id
964 */
965function media_tab_upload($ns, $auth = null, $jump = '')
966{
967    global $lang;
968    if (is_null($auth)) $auth = auth_quickaclcheck("$ns:*");
969
970    echo '<div class="upload">' . NL;
971    if ($auth >= AUTH_UPLOAD) {
972        echo '<p>' . $lang['mediaupload'] . '</p>';
973    }
974    media_uploadform($ns, $auth, true);
975    echo '</div>' . NL;
976}
977
978/**
979 * Prints tab that displays search form
980 *
981 * @author Kate Arzamastseva <pshns@ukr.net>
982 *
983 * @param string $ns
984 * @param null|int $auth permission level
985 */
986function media_tab_search($ns, $auth = null)
987{
988    global $INPUT;
989
990    $do = $INPUT->str('mediado');
991    $query = $INPUT->str('q');
992    echo '<div class="search">' . NL;
993
994    media_searchform($ns, $query, true);
995    if ($do == 'searchlist' || $query) {
996        media_searchlist($query, $ns, $auth, true, _media_get_sort_type());
997    }
998    echo '</div>' . NL;
999}
1000
1001/**
1002 * Prints tab that displays mediafile details
1003 *
1004 * @author Kate Arzamastseva <pshns@ukr.net>
1005 *
1006 * @param string     $image media id
1007 * @param string     $ns
1008 * @param null|int   $auth  permission level
1009 * @param string|int $rev   revision timestamp or empty string
1010 */
1011function media_tab_view($image, $ns, $auth = null, $rev = '')
1012{
1013    global $lang;
1014    if (is_null($auth)) $auth = auth_quickaclcheck("$ns:*");
1015
1016    if ($image && $auth >= AUTH_READ) {
1017        $mf = new MediaFile($image, $rev);
1018        echo (new Display($mf))->getDetailHtml();
1019        media_preview_buttons($image, $auth, $rev);
1020        media_details($image, $auth, $rev, $mf->getMeta());
1021    } else {
1022        echo '<div class="nothing">' . $lang['media_perm_read'] . '</div>' . NL;
1023    }
1024}
1025
1026/**
1027 * Prints tab that displays form for editing mediafile metadata
1028 *
1029 * @author Kate Arzamastseva <pshns@ukr.net>
1030 *
1031 * @param string     $image media id
1032 * @param string     $ns
1033 * @param null|int   $auth permission level
1034 */
1035function media_tab_edit($image, $ns, $auth = null)
1036{
1037    if (is_null($auth)) $auth = auth_quickaclcheck("$ns:*");
1038
1039    if ($image) {
1040        [, $mime] = mimetype($image);
1041        if ($mime == 'image/jpeg') media_metaform($image, $auth);
1042    }
1043}
1044
1045/**
1046 * Prints tab that displays mediafile revisions
1047 *
1048 * @author Kate Arzamastseva <pshns@ukr.net>
1049 *
1050 * @param string     $image media id
1051 * @param string     $ns
1052 * @param null|int   $auth permission level
1053 */
1054function media_tab_history($image, $ns, $auth = null)
1055{
1056    global $lang;
1057    global $INPUT;
1058
1059    if (is_null($auth)) $auth = auth_quickaclcheck("$ns:*");
1060    $do = $INPUT->str('mediado');
1061
1062    if ($auth >= AUTH_READ && $image) {
1063        if ($do == 'diff') {
1064            (new MediaDiff($image))->show(); //media_diff($image, $ns, $auth);
1065        } else {
1066            $first = $INPUT->int('first', -1);
1067            (new MediaRevisions($image))->show($first);
1068        }
1069    } else {
1070        echo '<div class="nothing">' . $lang['media_perm_read'] . '</div>' . NL;
1071    }
1072}
1073
1074/**
1075 * Prints mediafile action buttons
1076 *
1077 * @author Kate Arzamastseva <pshns@ukr.net>
1078 *
1079 * @param string     $image media id
1080 * @param int        $auth  permission level
1081 * @param int|string $rev   revision timestamp, or empty string
1082 */
1083function media_preview_buttons($image, $auth, $rev = '')
1084{
1085    global $lang, $conf;
1086
1087    echo '<ul class="actions">';
1088
1089    if ($auth >= AUTH_DELETE && !$rev && file_exists(mediaFN($image))) {
1090        // delete button
1091        $form = new Form([
1092            'id' => 'mediamanager__btn_delete',
1093            'action' => media_managerURL(['delete' => $image], '&'),
1094        ]);
1095        $form->addTagOpen('div')->addClass('no');
1096        $form->addButton('', $lang['btn_delete'])->attr('type', 'submit');
1097        $form->addTagClose('div');
1098        echo '<li>';
1099        echo $form->toHTML();
1100        echo '</li>';
1101    }
1102
1103    $auth_ow = (($conf['mediarevisions']) ? AUTH_UPLOAD : AUTH_DELETE);
1104    if ($auth >= $auth_ow && !$rev) {
1105        // upload new version button
1106        $form = new Form([
1107            'id' => 'mediamanager__btn_update',
1108            'action' => media_managerURL(['image' => $image, 'mediado' => 'update'], '&'),
1109        ]);
1110        $form->addTagOpen('div')->addClass('no');
1111        $form->addButton('', $lang['media_update'])->attr('type', 'submit');
1112        $form->addTagClose('div');
1113        echo '<li>';
1114        echo $form->toHTML();
1115        echo '</li>';
1116    }
1117
1118    if ($auth >= AUTH_UPLOAD && $rev && $conf['mediarevisions'] && file_exists(mediaFN($image, $rev))) {
1119        // restore button
1120        $form = new Form([
1121            'id' => 'mediamanager__btn_restore',
1122            'action' => media_managerURL(['image' => $image], '&'),
1123        ]);
1124        $form->addTagOpen('div')->addClass('no');
1125        $form->setHiddenField('mediado', 'restore');
1126        $form->setHiddenField('rev', $rev);
1127        $form->addButton('', $lang['media_restore'])->attr('type', 'submit');
1128        $form->addTagClose('div');
1129        echo '<li>';
1130        echo $form->toHTML();
1131        echo '</li>';
1132    }
1133
1134    echo '</ul>';
1135}
1136
1137/**
1138 * Returns the requested EXIF/IPTC tag from the image meta
1139 *
1140 * @author Kate Arzamastseva <pshns@ukr.net>
1141 *
1142 * @param array    $tags array with tags, first existing is returned
1143 * @param JpegMeta $meta
1144 * @param string   $alt  alternative value
1145 * @return string
1146 */
1147function media_getTag($tags, $meta = false, $alt = '')
1148{
1149    if (!$meta) return $alt;
1150    $info = $meta->getField($tags);
1151    if (!$info) return $alt;
1152    return $info;
1153}
1154
1155/**
1156 * Returns mediafile tags
1157 *
1158 * @author Kate Arzamastseva <pshns@ukr.net>
1159 *
1160 * @param JpegMeta $meta
1161 * @return array list of tags of the mediafile
1162 */
1163function media_file_tags($meta)
1164{
1165    // load the field descriptions
1166    static $fields = null;
1167    if (is_null($fields)) {
1168        $config_files = getConfigFiles('mediameta');
1169        foreach ($config_files as $config_file) {
1170            if (file_exists($config_file)) include($config_file);
1171        }
1172    }
1173
1174    $tags = [];
1175
1176    foreach ($fields as $tag) {
1177        $t = [];
1178        if (!empty($tag[0])) $t = [$tag[0]];
1179        if (isset($tag[3]) && is_array($tag[3])) $t = array_merge($t, $tag[3]);
1180        $value = media_getTag($t, $meta);
1181        $tags[] = ['tag' => $tag, 'value' => $value];
1182    }
1183
1184    return $tags;
1185}
1186
1187/**
1188 * Prints mediafile tags
1189 *
1190 * @author Kate Arzamastseva <pshns@ukr.net>
1191 *
1192 * @param string        $image image id
1193 * @param int           $auth  permission level
1194 * @param string|int    $rev   revision timestamp, or empty string
1195 * @param bool|JpegMeta $meta  image object, or create one if false
1196 */
1197function media_details($image, $auth, $rev = '', $meta = false)
1198{
1199    global $lang;
1200
1201    if (!$meta) $meta = new JpegMeta(mediaFN($image, $rev));
1202    $tags = media_file_tags($meta);
1203
1204    echo '<dl>' . NL;
1205    foreach ($tags as $tag) {
1206        if ($tag['value']) {
1207            $value = cleanText($tag['value']);
1208            echo '<dt>' . $lang[$tag['tag'][1]] . '</dt><dd>';
1209            if ($tag['tag'][2] == 'date') echo dformat($value);
1210            else echo hsc($value);
1211            echo '</dd>' . NL;
1212        }
1213    }
1214    echo '</dl>' . NL;
1215    echo '<dl>' . NL;
1216    echo '<dt>' . $lang['reference'] . ':</dt>';
1217    $media_usage = ft_mediause($image, true);
1218    if ($media_usage !== []) {
1219        foreach ($media_usage as $path) {
1220            echo '<dd>' . html_wikilink($path) . '</dd>';
1221        }
1222    } else {
1223        echo '<dd>' . $lang['nothingfound'] . '</dd>';
1224    }
1225    echo '</dl>' . NL;
1226}
1227
1228/**
1229 * Shows difference between two revisions of file
1230 *
1231 * @author Kate Arzamastseva <pshns@ukr.net>
1232 *
1233 * @param string $image  image id
1234 * @param string $ns
1235 * @param int $auth permission level
1236 * @param bool $fromajax
1237 *
1238 * @deprecated 2020-12-31
1239 */
1240function media_diff($image, $ns, $auth, $fromajax = false)
1241{
1242    dbg_deprecated('see ' . MediaDiff::class . '::show()');
1243}
1244
1245/**
1246 * Callback for media file diff
1247 *
1248 * @param array $data event data
1249 *
1250 * @deprecated 2020-12-31
1251 */
1252function _media_file_diff($data)
1253{
1254    dbg_deprecated('see ' . MediaDiff::class . '::show()');
1255}
1256
1257/**
1258 * Shows difference between two revisions of image
1259 *
1260 * @author Kate Arzamastseva <pshns@ukr.net>
1261 *
1262 * @param string $image
1263 * @param string|int $l_rev revision timestamp, or empty string
1264 * @param string|int $r_rev revision timestamp, or empty string
1265 * @param string $ns
1266 * @param int $auth permission level
1267 * @param bool $fromajax
1268 * @deprecated 2020-12-31
1269 */
1270function media_file_diff($image, $l_rev, $r_rev, $ns, $auth, $fromajax)
1271{
1272    dbg_deprecated('see ' . MediaDiff::class . '::showFileDiff()');
1273}
1274
1275/**
1276 * Prints two images side by side
1277 * and slider
1278 *
1279 * @author Kate Arzamastseva <pshns@ukr.net>
1280 *
1281 * @param string $image   image id
1282 * @param int    $l_rev   revision timestamp, or empty string
1283 * @param int    $r_rev   revision timestamp, or empty string
1284 * @param array  $l_size  array with width and height
1285 * @param array  $r_size  array with width and height
1286 * @param string $type
1287 * @deprecated 2020-12-31
1288 */
1289function media_image_diff($image, $l_rev, $r_rev, $l_size, $r_size, $type)
1290{
1291    dbg_deprecated('see ' . MediaDiff::class . '::showImageDiff()');
1292}
1293
1294/**
1295 * Restores an old revision of a media file
1296 *
1297 * @param string $image media id
1298 * @param int    $rev   revision timestamp or empty string
1299 * @param int    $auth
1300 * @return string - file's id
1301 *
1302 * @author Kate Arzamastseva <pshns@ukr.net>
1303 */
1304function media_restore($image, $rev, $auth)
1305{
1306    global $conf;
1307    if ($auth < AUTH_UPLOAD || !$conf['mediarevisions']) return false;
1308    $removed = (!file_exists(mediaFN($image)) && file_exists(mediaMetaFN($image, '.changes')));
1309    if (!$image || (!file_exists(mediaFN($image)) && !$removed)) return false;
1310    if (!$rev || !file_exists(mediaFN($image, $rev))) return false;
1311    [, $imime, ] = mimetype($image);
1312    $res = media_upload_finish(
1313        mediaFN($image, $rev),
1314        mediaFN($image),
1315        $image,
1316        $imime,
1317        true,
1318        'copy'
1319    );
1320    if (is_array($res)) {
1321        msg($res[0], $res[1]);
1322        return false;
1323    }
1324    return $res;
1325}
1326
1327/**
1328 * List all files found by the search request
1329 *
1330 * @author Tobias Sarnowski <sarnowski@cosmocode.de>
1331 * @author Andreas Gohr <gohr@cosmocode.de>
1332 * @author Kate Arzamastseva <pshns@ukr.net>
1333 * @triggers MEDIA_SEARCH
1334 *
1335 * @param string $query
1336 * @param string $ns
1337 * @param null|int $auth
1338 * @param bool $fullscreen
1339 * @param string $sort
1340 */
1341function media_searchlist($query, $ns, $auth = null, $fullscreen = false, $sort = 'natural')
1342{
1343    global $conf;
1344    global $lang;
1345
1346    $ns = cleanID($ns);
1347    $evdata = [
1348        'ns'    => $ns,
1349        'data'  => [],
1350        'query' => $query
1351    ];
1352    if (!blank($query)) {
1353        $evt = new Event('MEDIA_SEARCH', $evdata);
1354        if ($evt->advise_before()) {
1355            $dir = utf8_encodeFN(str_replace(':', '/', $evdata['ns']));
1356            $quoted = preg_quote($evdata['query'], '/');
1357            //apply globbing
1358            $quoted = str_replace(['\*', '\?'], ['.*', '.'], $quoted, $count);
1359
1360            //if we use globbing file name must match entirely but may be preceded by arbitrary namespace
1361            if ($count > 0) $quoted = '^([^:]*:)*' . $quoted . '$';
1362
1363            $pattern = '/' . $quoted . '/i';
1364            search(
1365                $evdata['data'],
1366                $conf['mediadir'],
1367                'search_mediafiles',
1368                ['showmsg' => false, 'pattern' => $pattern],
1369                $dir,
1370                1,
1371                $sort
1372            );
1373        }
1374        $evt->advise_after();
1375        unset($evt);
1376    }
1377
1378    if (!$fullscreen) {
1379        echo '<h1 id="media__ns">' . sprintf($lang['searchmedia_in'], hsc($ns) . ':*') . '</h1>' . NL;
1380        media_searchform($ns, $query);
1381    }
1382
1383    if (!count($evdata['data'])) {
1384        echo '<div class="nothing">' . $lang['nothingfound'] . '</div>' . NL;
1385    } else {
1386        if ($fullscreen) {
1387            echo '<ul class="' . _media_get_list_type() . '">';
1388        }
1389        foreach ($evdata['data'] as $item) {
1390            if (!$fullscreen) {
1391                // FIXME old call: media_printfile($item,$item['perm'],'',true);
1392                $display = new DisplayRow($item);
1393                $display->relativeDisplay($ns);
1394                $display->show();
1395            } else {
1396                // FIXME old call: media_printfile_thumbs($item,$item['perm'],false,true);
1397                $display = new DisplayTile($item);
1398                $display->relativeDisplay($ns);
1399                echo '<li>';
1400                $display->show();
1401                echo '</li>';
1402            }
1403        }
1404        if ($fullscreen) echo '</ul>' . NL;
1405    }
1406}
1407
1408/**
1409 * Display a media icon
1410 *
1411 * @param string $filename media id
1412 * @param string $size     the size subfolder, if not specified 16x16 is used
1413 * @return string html
1414 */
1415function media_printicon($filename, $size = '')
1416{
1417    [$ext] = mimetype(mediaFN($filename), false);
1418
1419    if (file_exists(DOKU_INC . 'lib/images/fileicons/' . $size . '/' . $ext . '.png')) {
1420        $icon = DOKU_BASE . 'lib/images/fileicons/' . $size . '/' . $ext . '.png';
1421    } else {
1422        $icon = DOKU_BASE . 'lib/images/fileicons/' . $size . '/file.png';
1423    }
1424
1425    return '<img src="' . $icon . '" alt="' . $filename . '" class="icon" />';
1426}
1427
1428/**
1429 * Build link based on the current, adding/rewriting parameters
1430 *
1431 * @author Kate Arzamastseva <pshns@ukr.net>
1432 *
1433 * @param array|bool $params
1434 * @param string     $amp           separator
1435 * @param bool       $abs           absolute url?
1436 * @param bool       $params_array  return the parmeters array?
1437 * @return string|array - link or link parameters
1438 */
1439function media_managerURL($params = false, $amp = '&amp;', $abs = false, $params_array = false)
1440{
1441    global $ID;
1442    global $INPUT;
1443
1444    $gets = ['do' => 'media'];
1445    $media_manager_params = ['tab_files', 'tab_details', 'image', 'ns', 'list', 'sort'];
1446    foreach ($media_manager_params as $x) {
1447        if ($INPUT->has($x)) $gets[$x] = $INPUT->str($x);
1448    }
1449
1450    if ($params) {
1451        $gets = $params + $gets;
1452    }
1453    unset($gets['id']);
1454    if (isset($gets['delete'])) {
1455        unset($gets['image']);
1456        unset($gets['tab_details']);
1457    }
1458
1459    if ($params_array) return $gets;
1460
1461    return wl($ID, $gets, $abs, $amp);
1462}
1463
1464/**
1465 * Print the media upload form if permissions are correct
1466 *
1467 * @author Andreas Gohr <andi@splitbrain.org>
1468 * @author Kate Arzamastseva <pshns@ukr.net>
1469 *
1470 * @param string $ns
1471 * @param int    $auth permission level
1472 * @param bool  $fullscreen
1473 */
1474function media_uploadform($ns, $auth, $fullscreen = false)
1475{
1476    global $lang;
1477    global $conf;
1478    global $INPUT;
1479
1480    if ($auth < AUTH_UPLOAD) {
1481        echo '<div class="nothing">' . $lang['media_perm_upload'] . '</div>' . NL;
1482        return;
1483    }
1484    $auth_ow = (($conf['mediarevisions']) ? AUTH_UPLOAD : AUTH_DELETE);
1485
1486    $update = false;
1487    $id = '';
1488    if ($auth >= $auth_ow && $fullscreen && $INPUT->str('mediado') == 'update') {
1489        $update = true;
1490        $id = cleanID($INPUT->str('image'));
1491    }
1492
1493    // The default HTML upload form
1494    $form = new Form([
1495        'id' => 'dw__upload',
1496        'enctype' => 'multipart/form-data',
1497        'action' => ($fullscreen)
1498                    ? media_managerURL(['tab_files' => 'files', 'tab_details' => 'view'], '&')
1499                    : DOKU_BASE . 'lib/exe/mediamanager.php',
1500    ]);
1501    $form->addTagOpen('div')->addClass('no');
1502    $form->setHiddenField('ns', hsc($ns));  // FIXME hsc required?
1503    $form->addTagOpen('p');
1504    $form->addTextInput('upload', $lang['txt_upload'])->id('upload__file')
1505            ->attrs(['type' => 'file']);
1506    $form->addTagClose('p');
1507    $form->addTagOpen('p');
1508    $form->addTextInput('mediaid', $lang['txt_filename'])->id('upload__name')
1509            ->val(noNS($id));
1510    $form->addButton('', $lang['btn_upload'])->attr('type', 'submit');
1511    $form->addTagClose('p');
1512    if ($auth >= $auth_ow) {
1513        $form->addTagOpen('p');
1514        $attrs = [];
1515        if ($update) $attrs['checked'] = 'checked';
1516        $form->addCheckbox('ow', $lang['txt_overwrt'])->id('dw__ow')->val('1')
1517            ->addClass('check')->attrs($attrs);
1518        $form->addTagClose('p');
1519    }
1520    $form->addTagClose('div');
1521
1522    if (!$fullscreen) {
1523        echo '<div class="upload">' . $lang['mediaupload'] . '</div>' . DOKU_LF;
1524    } else {
1525        echo DOKU_LF;
1526    }
1527
1528    echo '<div id="mediamanager__uploader">' . DOKU_LF;
1529    echo $form->toHTML('Upload');
1530    echo '</div>' . DOKU_LF;
1531
1532    echo '<p class="maxsize">';
1533    printf($lang['maxuploadsize'], filesize_h(media_getuploadsize()));
1534    echo ' <a class="allowedmime" href="#">' . $lang['allowedmime'] . '</a>';
1535    echo ' <span>' . implode(', ', array_keys(getMimeTypes())) . '</span>';
1536    echo '</p>' . DOKU_LF;
1537}
1538
1539/**
1540 * Returns the size uploaded files may have
1541 *
1542 * This uses a conservative approach using the lowest number found
1543 * in any of the limiting ini settings
1544 *
1545 * @returns int size in bytes
1546 */
1547function media_getuploadsize()
1548{
1549    $okay = 0;
1550
1551    $post = php_to_byte(@ini_get('post_max_size'));
1552    $suho = php_to_byte(@ini_get('suhosin.post.max_value_length'));
1553    $upld = php_to_byte(@ini_get('upload_max_filesize'));
1554
1555    if ($post && ($post < $okay || $okay === 0)) $okay = $post;
1556    if ($suho && ($suho < $okay || $okay == 0)) $okay = $suho;
1557    if ($upld && ($upld < $okay || $okay == 0)) $okay = $upld;
1558
1559    return $okay;
1560}
1561
1562/**
1563 * Print the search field form
1564 *
1565 * @author Tobias Sarnowski <sarnowski@cosmocode.de>
1566 * @author Kate Arzamastseva <pshns@ukr.net>
1567 *
1568 * @param string $ns
1569 * @param string $query
1570 * @param bool $fullscreen
1571 */
1572function media_searchform($ns, $query = '', $fullscreen = false)
1573{
1574    global $lang;
1575
1576    // The default HTML search form
1577    $form = new Form([
1578        'id'     => 'dw__mediasearch',
1579        'action' => ($fullscreen)
1580                    ? media_managerURL([], '&')
1581                    : DOKU_BASE . 'lib/exe/mediamanager.php',
1582    ]);
1583    $form->addTagOpen('div')->addClass('no');
1584    $form->setHiddenField('ns', $ns);
1585    $form->setHiddenField($fullscreen ? 'mediado' : 'do', 'searchlist');
1586
1587    $form->addTagOpen('p');
1588    $form->addTextInput('q', $lang['searchmedia'])
1589            ->attr('title', sprintf($lang['searchmedia_in'], hsc($ns) . ':*'))
1590            ->val($query);
1591    $form->addHTML(' ');
1592    $form->addButton('', $lang['btn_search'])->attr('type', 'submit');
1593    $form->addTagClose('p');
1594    $form->addTagClose('div');
1595    echo $form->toHTML('SearchMedia');
1596}
1597
1598/**
1599 * Build a tree outline of available media namespaces
1600 *
1601 * @author Andreas Gohr <andi@splitbrain.org>
1602 *
1603 * @param string $ns
1604 */
1605function media_nstree($ns)
1606{
1607    global $conf;
1608    global $lang;
1609
1610    // currently selected namespace
1611    $ns = cleanID($ns);
1612    if (empty($ns)) {
1613        global $ID;
1614        $ns = (string)getNS($ID);
1615    }
1616
1617    $ns_dir = utf8_encodeFN(str_replace(':', '/', $ns));
1618
1619    $data = [];
1620    search($data, $conf['mediadir'], 'search_index', ['ns' => $ns_dir, 'nofiles' => true]);
1621
1622    // wrap a list with the root level around the other namespaces
1623    array_unshift($data, ['level' => 0, 'id' => '', 'open' => 'true', 'label' => '[' . $lang['mediaroot'] . ']']);
1624
1625    // insert the current ns into the hierarchy if it isn't already part of it
1626    $ns_parts = explode(':', $ns);
1627    $tmp_ns = '';
1628    $pos = 0;
1629    $insert = false;
1630    foreach ($ns_parts as $level => $part) {
1631        if ($tmp_ns) {
1632            $tmp_ns .= ':' . $part;
1633        } else {
1634            $tmp_ns = $part;
1635        }
1636
1637        // find the namespace parts
1638        while (array_key_exists($pos, $data) && $data[$pos]['id'] != $tmp_ns) {
1639            if (
1640                $pos >= count($data) ||
1641                ($data[$pos]['level'] <= $level + 1 && Sort::strcmp($data[$pos]['id'], $tmp_ns) > 0)
1642            ) {
1643                $insert = true;
1644                break;
1645            }
1646            ++$pos;
1647        }
1648        // insert namespace in hierarchy; if not found in above loop, append it to the end
1649        if ($insert || $pos === count($data)) {
1650            array_splice($data, $pos, 0, [['level' => $level + 1, 'id' => $tmp_ns, 'open' => 'true']]);
1651        }
1652    }
1653
1654    echo html_buildlist($data, 'idx', 'media_nstree_item', 'media_nstree_li');
1655}
1656
1657/**
1658 * Userfunction for html_buildlist
1659 *
1660 * Prints a media namespace tree item
1661 *
1662 * @author Andreas Gohr <andi@splitbrain.org>
1663 *
1664 * @param array $item
1665 * @return string html
1666 */
1667function media_nstree_item($item)
1668{
1669    global $INPUT;
1670    $pos   = strrpos($item['id'], ':');
1671    $label = substr($item['id'], $pos > 0 ? $pos + 1 : 0);
1672    if (empty($item['label'])) $item['label'] = $label;
1673
1674    $ret  = '';
1675    if ($INPUT->str('do') != 'media')
1676    $ret .= '<a href="' . DOKU_BASE . 'lib/exe/mediamanager.php?ns=' . idfilter($item['id']) . '" class="idx_dir">';
1677    else $ret .= '<a href="' . media_managerURL(['ns' => idfilter($item['id'], false), 'tab_files' => 'files'])
1678        . '" class="idx_dir">';
1679    $ret .= $item['label'];
1680    $ret .= '</a>';
1681    return $ret;
1682}
1683
1684/**
1685 * Userfunction for html_buildlist
1686 *
1687 * Prints a media namespace tree item opener
1688 *
1689 * @author Andreas Gohr <andi@splitbrain.org>
1690 *
1691 * @param array $item
1692 * @return string html
1693 */
1694function media_nstree_li($item)
1695{
1696    $class = 'media level' . $item['level'];
1697    if ($item['open']) {
1698        $class .= ' open';
1699        $img   = DOKU_BASE . 'lib/images/minus.gif';
1700        $alt   = '−';
1701    } else {
1702        $class .= ' closed';
1703        $img   = DOKU_BASE . 'lib/images/plus.gif';
1704        $alt   = '+';
1705    }
1706    // TODO: only deliver an image if it actually has a subtree...
1707    return '<li class="' . $class . '">' .
1708        '<img src="' . $img . '" alt="' . $alt . '" />';
1709}
1710
1711/**
1712 * Resizes or crop the given image to the given size
1713 *
1714 * @author  Andreas Gohr <andi@splitbrain.org>
1715 *
1716 * @param string $file filename, path to file
1717 * @param string $ext  extension
1718 * @param int    $w    desired width
1719 * @param int    $h    desired height
1720 * @param bool   $crop should a center crop be used?
1721 * @return string path to resized or original size if failed
1722 */
1723function media_mod_image($file, $ext, $w, $h = 0, $crop = false)
1724{
1725    global $conf;
1726    if (!$h) $h = 0;
1727    // we wont scale up to infinity
1728    if ($w > 2000 || $h > 2000) return $file;
1729
1730    $operation = $crop ? 'crop' : 'resize';
1731
1732    $options = [
1733        'quality' => $conf['jpg_quality'],
1734        'imconvert' => $conf['im_convert'],
1735    ];
1736
1737    $cache = new CacheImageMod($file, $w, $h, $ext, $crop);
1738    if (!$cache->useCache()) {
1739        try {
1740            Slika::run($file, $options)
1741                 ->autorotate()
1742                 ->$operation($w, $h)
1743                 ->save($cache->cache, $ext);
1744            if ($conf['fperm']) @chmod($cache->cache, $conf['fperm']);
1745        } catch (Exception $e) {
1746            Logger::debug($e->getMessage());
1747            return $file;
1748        }
1749    }
1750
1751    return $cache->cache;
1752}
1753
1754/**
1755 * Resizes the given image to the given size
1756 *
1757 * @author  Andreas Gohr <andi@splitbrain.org>
1758 *
1759 * @param string $file filename, path to file
1760 * @param string $ext  extension
1761 * @param int    $w    desired width
1762 * @param int    $h    desired height
1763 * @return string path to resized or original size if failed
1764 */
1765function media_resize_image($file, $ext, $w, $h = 0)
1766{
1767    return media_mod_image($file, $ext, $w, $h, false);
1768}
1769
1770/**
1771 * Center crops the given image to the wanted size
1772 *
1773 * @author  Andreas Gohr <andi@splitbrain.org>
1774 *
1775 * @param string $file filename, path to file
1776 * @param string $ext  extension
1777 * @param int    $w    desired width
1778 * @param int    $h    desired height
1779 * @return string path to resized or original size if failed
1780 */
1781function media_crop_image($file, $ext, $w, $h = 0)
1782{
1783    return media_mod_image($file, $ext, $w, $h, true);
1784}
1785
1786/**
1787 * Calculate a token to be used to verify fetch requests for resized or
1788 * cropped images have been internally generated - and prevent external
1789 * DDOS attacks via fetch
1790 *
1791 * @author Christopher Smith <chris@jalakai.co.uk>
1792 *
1793 * @param string  $id    id of the image
1794 * @param int     $w     resize/crop width
1795 * @param int     $h     resize/crop height
1796 * @return string token or empty string if no token required
1797 */
1798function media_get_token($id, $w, $h)
1799{
1800    // token is only required for modified images
1801    if ($w || $h || media_isexternal($id)) {
1802        $token = $id;
1803        if ($w) $token .= '.' . $w;
1804        if ($h) $token .= '.' . $h;
1805
1806        return substr(PassHash::hmac('md5', $token, auth_cookiesalt()), 0, 6);
1807    }
1808
1809    return '';
1810}
1811
1812/**
1813 * Download a remote file and return local filename
1814 *
1815 * returns false if download fails. Uses cached file if available and
1816 * wanted
1817 *
1818 * @author  Andreas Gohr <andi@splitbrain.org>
1819 * @author  Pavel Vitis <Pavel.Vitis@seznam.cz>
1820 *
1821 * @param string $url
1822 * @param string $ext   extension
1823 * @param int    $cache cachetime in seconds
1824 * @return false|string path to cached file
1825 */
1826function media_get_from_URL($url, $ext, $cache)
1827{
1828    global $conf;
1829
1830    // if no cache or fetchsize just redirect
1831    if ($cache == 0)           return false;
1832    if (!$conf['fetchsize']) return false;
1833
1834    $local = getCacheName(strtolower($url), ".media.$ext");
1835    $mtime = @filemtime($local); // 0 if not exists
1836
1837    //decide if download needed:
1838    if (
1839        ($mtime == 0) || // cache does not exist
1840        ($cache != -1 && $mtime < time() - $cache) // 'recache' and cache has expired
1841    ) {
1842        if (media_image_download($url, $local)) {
1843            return $local;
1844        } else {
1845            return false;
1846        }
1847    }
1848
1849    //if cache exists use it else
1850    if ($mtime) return $local;
1851
1852    //else return false
1853    return false;
1854}
1855
1856/**
1857 * Download image files
1858 *
1859 * @author Andreas Gohr <andi@splitbrain.org>
1860 *
1861 * @param string $url
1862 * @param string $file path to file in which to put the downloaded content
1863 * @return bool
1864 */
1865function media_image_download($url, $file)
1866{
1867    global $conf;
1868    $http = new DokuHTTPClient();
1869    $http->keep_alive = false; // we do single ops here, no need for keep-alive
1870
1871    $http->max_bodysize = $conf['fetchsize'];
1872    $http->timeout = 25; //max. 25 sec
1873    $http->header_regexp = '!\r\nContent-Type: image/(jpe?g|gif|png)!i';
1874
1875    $data = $http->get($url);
1876    if (!$data) return false;
1877
1878    $fileexists = file_exists($file);
1879    $fp = @fopen($file, "w");
1880    if (!$fp) return false;
1881    fwrite($fp, $data);
1882    fclose($fp);
1883    if (!$fileexists && $conf['fperm']) chmod($file, $conf['fperm']);
1884
1885    // check if it is really an image
1886    $info = @getimagesize($file);
1887    if (!$info) {
1888        @unlink($file);
1889        return false;
1890    }
1891
1892    return true;
1893}
1894
1895/**
1896 * resize images using external ImageMagick convert program
1897 *
1898 * @author Pavel Vitis <Pavel.Vitis@seznam.cz>
1899 * @author Andreas Gohr <andi@splitbrain.org>
1900 *
1901 * @param string $ext     extension
1902 * @param string $from    filename path to file
1903 * @param int    $from_w  original width
1904 * @param int    $from_h  original height
1905 * @param string $to      path to resized file
1906 * @param int    $to_w    desired width
1907 * @param int    $to_h    desired height
1908 * @return bool
1909 */
1910function media_resize_imageIM($ext, $from, $from_w, $from_h, $to, $to_w, $to_h)
1911{
1912    global $conf;
1913
1914    // check if convert is configured
1915    if (!$conf['im_convert']) return false;
1916
1917    // prepare command
1918    $cmd  = $conf['im_convert'];
1919    $cmd .= ' -resize ' . $to_w . 'x' . $to_h . '!';
1920    if ($ext == 'jpg' || $ext == 'jpeg') {
1921        $cmd .= ' -quality ' . $conf['jpg_quality'];
1922    }
1923    $cmd .= " $from $to";
1924
1925    @exec($cmd, $out, $retval);
1926    if ($retval == 0) return true;
1927    return false;
1928}
1929
1930/**
1931 * crop images using external ImageMagick convert program
1932 *
1933 * @author Andreas Gohr <andi@splitbrain.org>
1934 *
1935 * @param string $ext     extension
1936 * @param string $from    filename path to file
1937 * @param int    $from_w  original width
1938 * @param int    $from_h  original height
1939 * @param string $to      path to resized file
1940 * @param int    $to_w    desired width
1941 * @param int    $to_h    desired height
1942 * @param int    $ofs_x   offset of crop centre
1943 * @param int    $ofs_y   offset of crop centre
1944 * @return bool
1945 * @deprecated 2020-09-01
1946 */
1947function media_crop_imageIM($ext, $from, $from_w, $from_h, $to, $to_w, $to_h, $ofs_x, $ofs_y)
1948{
1949    global $conf;
1950    dbg_deprecated('splitbrain\\Slika');
1951
1952    // check if convert is configured
1953    if (!$conf['im_convert']) return false;
1954
1955    // prepare command
1956    $cmd  = $conf['im_convert'];
1957    $cmd .= ' -crop ' . $to_w . 'x' . $to_h . '+' . $ofs_x . '+' . $ofs_y;
1958    if ($ext == 'jpg' || $ext == 'jpeg') {
1959        $cmd .= ' -quality ' . $conf['jpg_quality'];
1960    }
1961    $cmd .= " $from $to";
1962
1963    @exec($cmd, $out, $retval);
1964    if ($retval == 0) return true;
1965    return false;
1966}
1967
1968/**
1969 * resize or crop images using PHP's libGD support
1970 *
1971 * @author Andreas Gohr <andi@splitbrain.org>
1972 * @author Sebastian Wienecke <s_wienecke@web.de>
1973 *
1974 * @param string $ext     extension
1975 * @param string $from    filename path to file
1976 * @param int    $from_w  original width
1977 * @param int    $from_h  original height
1978 * @param string $to      path to resized file
1979 * @param int    $to_w    desired width
1980 * @param int    $to_h    desired height
1981 * @param int    $ofs_x   offset of crop centre
1982 * @param int    $ofs_y   offset of crop centre
1983 * @return bool
1984 * @deprecated 2020-09-01
1985 */
1986function media_resize_imageGD($ext, $from, $from_w, $from_h, $to, $to_w, $to_h, $ofs_x = 0, $ofs_y = 0)
1987{
1988    global $conf;
1989    dbg_deprecated('splitbrain\\Slika');
1990
1991    if ($conf['gdlib'] < 1) return false; //no GDlib available or wanted
1992
1993    // check available memory
1994    if (!is_mem_available(($from_w * $from_h * 4) + ($to_w * $to_h * 4))) {
1995        return false;
1996    }
1997
1998    // create an image of the given filetype
1999    $image = false;
2000    if ($ext == 'jpg' || $ext == 'jpeg') {
2001        if (!function_exists("imagecreatefromjpeg")) return false;
2002        $image = @imagecreatefromjpeg($from);
2003    } elseif ($ext == 'png') {
2004        if (!function_exists("imagecreatefrompng")) return false;
2005        $image = @imagecreatefrompng($from);
2006    } elseif ($ext == 'gif') {
2007        if (!function_exists("imagecreatefromgif")) return false;
2008        $image = @imagecreatefromgif($from);
2009    }
2010    if (!$image) return false;
2011
2012    $newimg = false;
2013    if (($conf['gdlib'] > 1) && function_exists("imagecreatetruecolor") && $ext != 'gif') {
2014        $newimg = @imagecreatetruecolor($to_w, $to_h);
2015    }
2016    if (!$newimg) $newimg = @imagecreate($to_w, $to_h);
2017    if (!$newimg) {
2018        imagedestroy($image);
2019        return false;
2020    }
2021
2022    //keep png alpha channel if possible
2023    if ($ext == 'png' && $conf['gdlib'] > 1 && function_exists('imagesavealpha')) {
2024        imagealphablending($newimg, false);
2025        imagesavealpha($newimg, true);
2026    }
2027
2028    //keep gif transparent color if possible
2029    if ($ext == 'gif' && function_exists('imagefill') && function_exists('imagecolorallocate')) {
2030        if (function_exists('imagecolorsforindex') && function_exists('imagecolortransparent')) {
2031            $transcolorindex = @imagecolortransparent($image);
2032            if ($transcolorindex >= 0) { //transparent color exists
2033                $transcolor = @imagecolorsforindex($image, $transcolorindex);
2034                $transcolorindex = @imagecolorallocate(
2035                    $newimg,
2036                    $transcolor['red'],
2037                    $transcolor['green'],
2038                    $transcolor['blue']
2039                );
2040                @imagefill($newimg, 0, 0, $transcolorindex);
2041                @imagecolortransparent($newimg, $transcolorindex);
2042            } else { //filling with white
2043                $whitecolorindex = @imagecolorallocate($newimg, 255, 255, 255);
2044                @imagefill($newimg, 0, 0, $whitecolorindex);
2045            }
2046        } else { //filling with white
2047            $whitecolorindex = @imagecolorallocate($newimg, 255, 255, 255);
2048            @imagefill($newimg, 0, 0, $whitecolorindex);
2049        }
2050    }
2051
2052    //try resampling first
2053    if (function_exists("imagecopyresampled")) {
2054        if (!@imagecopyresampled($newimg, $image, 0, 0, $ofs_x, $ofs_y, $to_w, $to_h, $from_w, $from_h)) {
2055            imagecopyresized($newimg, $image, 0, 0, $ofs_x, $ofs_y, $to_w, $to_h, $from_w, $from_h);
2056        }
2057    } else {
2058        imagecopyresized($newimg, $image, 0, 0, $ofs_x, $ofs_y, $to_w, $to_h, $from_w, $from_h);
2059    }
2060
2061    $okay = false;
2062    if ($ext == 'jpg' || $ext == 'jpeg') {
2063        if (!function_exists('imagejpeg')) {
2064            $okay = false;
2065        } else {
2066            $okay = imagejpeg($newimg, $to, $conf['jpg_quality']);
2067        }
2068    } elseif ($ext == 'png') {
2069        if (!function_exists('imagepng')) {
2070            $okay = false;
2071        } else {
2072            $okay =  imagepng($newimg, $to);
2073        }
2074    } elseif ($ext == 'gif') {
2075        if (!function_exists('imagegif')) {
2076            $okay = false;
2077        } else {
2078            $okay = imagegif($newimg, $to);
2079        }
2080    }
2081
2082    // destroy GD image resources
2083    imagedestroy($image);
2084    imagedestroy($newimg);
2085
2086    return $okay;
2087}
2088
2089/**
2090 * Return other media files with the same base name
2091 * but different extensions.
2092 *
2093 * @param string   $src     - ID of media file
2094 * @param string[] $exts    - alternative extensions to find other files for
2095 * @return array            - array(mime type => file ID)
2096 *
2097 * @author Anika Henke <anika@selfthinker.org>
2098 */
2099function media_alternativefiles($src, $exts)
2100{
2101
2102    $files = [];
2103    [$srcExt, /* srcMime */] = mimetype($src);
2104    $filebase = substr($src, 0, -1 * (strlen($srcExt) + 1));
2105
2106    foreach ($exts as $ext) {
2107        $fileid = $filebase . '.' . $ext;
2108        $file = mediaFN($fileid);
2109        if (file_exists($file)) {
2110            [/* fileExt */, $fileMime] = mimetype($file);
2111            $files[$fileMime] = $fileid;
2112        }
2113    }
2114    return $files;
2115}
2116
2117/**
2118 * Check if video/audio is supported to be embedded.
2119 *
2120 * @param string $mime      - mimetype of media file
2121 * @param string $type      - type of media files to check ('video', 'audio', or null for all)
2122 * @return boolean
2123 *
2124 * @author Anika Henke <anika@selfthinker.org>
2125 */
2126function media_supportedav($mime, $type = null)
2127{
2128    $supportedAudio = [
2129        'ogg' => 'audio/ogg',
2130        'mp3' => 'audio/mpeg',
2131        'wav' => 'audio/wav'
2132    ];
2133    $supportedVideo = [
2134        'webm' => 'video/webm',
2135        'ogv' => 'video/ogg',
2136        'mp4' => 'video/mp4'
2137    ];
2138    if ($type == 'audio') {
2139        $supportedAv = $supportedAudio;
2140    } elseif ($type == 'video') {
2141        $supportedAv = $supportedVideo;
2142    } else {
2143        $supportedAv = array_merge($supportedAudio, $supportedVideo);
2144    }
2145    return in_array($mime, $supportedAv);
2146}
2147
2148/**
2149 * Return track media files with the same base name
2150 * but extensions that indicate kind and lang.
2151 * ie for foo.webm search foo.sub.lang.vtt, foo.cap.lang.vtt...
2152 *
2153 * @param string   $src     - ID of media file
2154 * @return array            - array(mediaID => array( kind, srclang ))
2155 *
2156 * @author Schplurtz le Déboulonné <Schplurtz@laposte.net>
2157 */
2158function media_trackfiles($src)
2159{
2160    $kinds = [
2161        'sub' => 'subtitles',
2162        'cap' => 'captions',
2163        'des' => 'descriptions',
2164        'cha' => 'chapters',
2165        'met' => 'metadata'
2166    ];
2167
2168    $files = [];
2169    $re = '/\\.(sub|cap|des|cha|met)\\.([^.]+)\\.vtt$/';
2170    $baseid = pathinfo($src, PATHINFO_FILENAME);
2171    $pattern = mediaFN($baseid) . '.*.*.vtt';
2172    $list = glob($pattern);
2173    foreach ($list as $track) {
2174        if (preg_match($re, $track, $matches)) {
2175            $files[$baseid . '.' . $matches[1] . '.' . $matches[2] . '.vtt'] = [$kinds[$matches[1]], $matches[2]];
2176        }
2177    }
2178    return $files;
2179}
2180
2181/* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */
2182