1<?php
2/**
3 * DokuWiki Plugin PreserveFilenames / action_anteater.php
4 *
5 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html)
6 * @author  Kazutaka Miyasaka <kazmiya@gmail.com>
7 */
8
9// must be run within DokuWiki
10if (!defined('DOKU_INC')) {
11    die();
12}
13
14if (!defined('DOKU_PLUGIN')) {
15    define('DOKU_PLUGIN', DOKU_INC . 'lib/plugins/');
16}
17
18require_once(DOKU_PLUGIN . 'action.php');
19require_once(DOKU_PLUGIN . 'preservefilenames/common.php');
20
21class action_plugin_preservefilenames_anteater extends DokuWiki_Action_Plugin
22{
23    /**
24     * Common functions
25     */
26    protected $common;
27
28    /**
29     * Registers event handlers
30     */
31    function register(&$controller)
32    {
33        $this->common = new PreserveFilenames_Common();
34
35        $controller->register_hook('MEDIA_UPLOAD_FINISH',          'AFTER',  $this, '_saveMeta');
36        $controller->register_hook('MEDIA_DELETE_FILE',            'AFTER',  $this, '_deleteMeta');
37        $controller->register_hook('MEDIA_SENDFILE',               'BEFORE', $this, '_sendFile');
38        $controller->register_hook('PARSER_HANDLER_DONE',          'BEFORE', $this, '_replaceLinkTitle');
39        $controller->register_hook('RENDERER_CONTENT_POSTPROCESS', 'AFTER',  $this, '_replaceLinkURL');
40        $controller->register_hook('MEDIAMANAGER_STARTED',         'AFTER',  $this, '_exportToJSINFO');
41        $controller->register_hook('MEDIAMANAGER_CONTENT_OUTPUT',  'BEFORE', $this, '_showMediaList');
42        $controller->register_hook('AJAX_CALL_UNKNOWN',            'BEFORE', $this, '_showMediaListAjax');
43        $controller->register_hook('ACTION_ACT_PREPROCESS',        'BEFORE', $this, '_replaceSnippetDownload');
44    }
45
46    /**
47     * Saves the name of the uploaded media file to a meta file
48     */
49    function _saveMeta(&$event)
50    {
51        global $conf;
52
53        $id = $event->data[2];
54        $filename_tidy = noNS($id);
55
56        // retrieve original filename
57        if (!empty($_POST['id'])) {
58            // via normal uploader
59            $filename_pat = $conf['useslash'] ? '/([^:;\/]*)$/' : '/([^:;]*)$/';
60            preg_match($filename_pat, $_POST['id'], $matches);
61            $filename_orig = $matches[1];
62        } elseif (isset($_FILES['Filedata'])) {
63            // via multiuploader
64            $filename_orig = $_FILES['upload']['name'];
65        } else {
66            return;
67        }
68
69        $filename_safe = $this->common->_sanitizeFileName($filename_orig);
70
71        // no need to save original filename
72        if ($filename_tidy === $filename_safe) {
73            return;
74        }
75
76        // fallback if suspicious characters found
77        if ($filename_orig !== $filename_safe) {
78            return;
79        }
80
81        // save original filename to metadata
82        $metafile = metaFN($id, '.filename');
83        io_saveFile($metafile, serialize(array(
84            'filename' => $filename_safe,
85        )));
86    }
87
88    /**
89     * Deletes a meta file associated with the deleted media file
90     */
91    function _deleteMeta(&$event)
92    {
93        $id = $event->data['id'];
94        $metafile = metaFN($id, '.filename');
95
96        if (@unlink($metafile)) {
97            io_sweepNS($id, 'metadir');
98        }
99    }
100
101    /**
102     * Sends a media file with its original filename
103     *
104     * @see sendFile() in lib/exe/fetch.php
105     */
106    function _sendFile(&$event)
107    {
108        global $conf;
109        global $MEDIA;
110
111        $d = $event->data;
112        $event->preventDefault();
113        list($file, $mime, $dl, $cache) = array($d['file'], $d['mime'], $d['download'], $d['cache']);
114
115        $fmtime = @filemtime($file);
116
117        // send headers
118        header("Content-Type: $mime");
119
120        // smart http caching headers
121        if ($cache == -1) {
122            // cache
123            // cachetime or one hour
124            header('Expires: ' . gmdate('D, d M Y H:i:s', time() + max($conf['cachetime'], 3600)) . ' GMT');
125            header('Cache-Control: public, proxy-revalidate, no-transform, max-age=' . max($conf['cachetime'], 3600));
126            header('Pragma: public');
127        } elseif ($cache > 0) {
128            // recache
129            // remaining cachetime + 10 seconds so the newly recached media is used
130            header('Expires: ' . gmdate("D, d M Y H:i:s", $fmtime + $conf['cachetime'] + 10) . ' GMT');
131            header('Cache-Control: public, proxy-revalidate, no-transform, max-age=' . max($fmtime - time() + $conf['cachetime'] + 10, 0));
132            header('Pragma: public');
133        } elseif ($cache == 0) {
134            // nocache
135            header('Cache-Control: must-revalidate, no-transform, post-check=0, pre-check=0');
136            header('Pragma: public');
137        }
138
139        // send important headers first, script stops here if '304 Not Modified' response
140        http_conditionalRequest($fmtime);
141
142        // retrieve original filename and send Content-Disposition header
143        $filename = $this->_getOriginalFileName($MEDIA);
144
145        if ($filename === false) {
146            $filename = utf8_decodeFN($this->common->_correctBasename($d['file']));
147        }
148
149        header($this->common->_buildContentDispositionHeader($dl, $filename));
150
151        // use x-sendfile header to pass the delivery to compatible webservers
152        if (http_sendfile($file)) {
153            exit;
154        }
155
156        // send file contents
157        $fp = @fopen($file, 'rb');
158
159        if ($fp) {
160            http_rangeRequest($fp, filesize($file), $mime);
161        } else {
162            header('HTTP/1.0 500 Internal Server Error');
163            print "Could not read $file - bad permissions?";
164        }
165    }
166
167    /**
168     * Replaces titles of non-labeled internal media links with their original filenames
169     */
170    function _replaceLinkTitle(&$event)
171    {
172        global $ID;
173        global $conf;
174
175        require_once(DOKU_INC . 'inc/JpegMeta.php');
176
177        $ns = getNS($ID);
178
179        // get the instructions list from the handler
180        $calls =& $event->data->calls;
181
182        // array index numbers for readability
183        list($handler_name, $instructions, $source, $title, $linking) = array(0, 1, 0, 1, 6);
184
185        // scan media link and mark it with its original filename
186        $last = count($calls) - 1;
187
188        for ($i = 0; $i <= $last; $i++) {
189            // NOTE: 'externalmedia' is processed here because there is a
190            //       basename() bug in fetching its auto-filled linktext.
191            //       For more details please see common->_correctBasename().
192            if (!preg_match('/^(?:in|ex)ternalmedia$/', $calls[$i][$handler_name])) {
193                continue;
194            }
195
196            $inst =& $calls[$i][$instructions];
197            $filename = false;
198            $linktext = $inst[$title];
199            $linkonly = ($inst[$linking] === 'linkonly');
200            list($src, $hash) = explode('#', $inst[$source], 2);
201
202            // get original filename
203            if ($calls[$i][$handler_name] === 'internalmedia') {
204                resolve_mediaid($ns, $src, $exists);
205                list($ext, $mime, $dl) = mimetype($src);
206                $filename = $this->_getOriginalFileName($src);
207            } else {
208                list($ext, $mime, $dl) = mimetype($src);
209            }
210
211            // prefetch auto-filled linktext
212            if (!$linktext) {
213                if (
214                    substr($mime, 0, 5) === 'image'
215                    && ($ext === 'jpg' || $ext === 'jpeg')
216                    && ($jpeg = new JpegMeta(mediaFN($src)))
217                    && ($caption = $jpeg->getTitle())
218                ) {
219                    $linktext = $caption;
220                } else {
221                    $linktext = $this->common->_correctBasename(noNS($src));
222                }
223            }
224
225            // add a marker (normally you cannot put '}}' in a media link title
226            // and cannot put ':' in a filename)
227            if ($filename === false) {
228                $inst[$title] = $linktext;
229            } elseif ($inst[$title] !== $linktext) {
230                $inst[$title] = $linktext . '}}preservefilenames:autofilled:' . $filename;
231            } else {
232                $inst[$title] = $linktext . '}}preservefilenames::' . $filename;
233            }
234        }
235    }
236
237    /**
238     * Replaces url and title of a link which has original filename info
239     */
240    function _replaceLinkURL(&$event)
241    {
242        if ($event->data[0] !== 'xhtml') {
243            return;
244        }
245
246        // image link
247        $event->data[1] = preg_replace_callback(
248            '/
249                <a ([^>]*)>
250                (?:
251                    ([^<>\}]*)\}\}preservefilenames:(autofilled)?:([^<]*)
252                    |
253                    (<img [^>]*?alt="([^"]*)\}\}preservefilenames:(autofilled)?:([^"]*)"[^>]*>)
254                )
255                <\/a>
256            /x',
257            array(self, '_replaceLinkURL_callback_a'),
258            $event->data[1]
259        );
260
261        // embedded image
262        $event->data[1] = preg_replace_callback(
263            '/<img [^>]*?alt="([^"]*)\}\}preservefilenames:(autofilled)?:([^"]*)"[^>]*>/',
264            array(self, '_replaceLinkURL_callback_img'),
265            $event->data[1]
266        );
267    }
268
269    /**
270     * Callback function for _replaceLinkURL (link)
271     */
272    static function _replaceLinkURL_callback_a($matches)
273    {
274        list($atag, $attr_str, $linktext, $autofilled, $filename) = array_slice($matches, 0, 5);
275
276        if (!preg_match('/class="media[" ]/', $attr_str)) {
277            return $atag;
278        }
279
280        if (isset($matches[5])) {
281            // image link
282            $filename = $matches[8];
283            $linktext = self::_replaceLinkURL_callback_img(array_slice($matches, 5, 4));
284        } else {
285            // text link
286            if ($autofilled) {
287                $linktext = $filename;
288            }
289        }
290
291        $filename = htmlspecialchars_decode($filename, ENT_QUOTES);
292        $pageid = cleanID($filename);
293
294        $attr_str = preg_replace(
295            array(
296                '/(href="[^"]*)' . preg_quote(rawurlencode($pageid)) . '((?:\?[^#]*)?(?:#[^"]*)?")/',
297                '/(title="[^"]*)' . preg_quote($pageid) . '(")/'
298            ),
299            array(
300                "\${1}" . rawurlencode($filename) . '\2',
301                "\${1}" . hsc($filename) . '\2'
302            ),
303            $attr_str
304        );
305
306        return '<a ' . $attr_str . '>' . $linktext . '</a>';
307    }
308
309    /**
310     * Callback function for _replaceLinkURL (image)
311     */
312    static function _replaceLinkURL_callback_img($matches)
313    {
314        list($imgtag, $imgtitle, $autofilled, $filename) = $matches;
315
316        if (!preg_match('/class="media(?:left|right|center)?[" ]/', $imgtag)) {
317            return $imgtag;
318        }
319
320        if ($autofilled) {
321            $imgtitle = $filename;
322        }
323
324        $filename = htmlspecialchars_decode($filename, ENT_QUOTES);
325        $pageid = cleanID($filename);
326
327        $imgtag = preg_replace(
328            array(
329                '/(src="[^"]*)' . preg_quote(rawurlencode($pageid)) . '((?:\?[^#]*)?(?:#[^"]*)?")/',
330                '/(alt|title)="[^"]*"/'
331            ),
332            array(
333                "\${1}" . rawurlencode($filename) . '\2',
334                '\1="' . $imgtitle . '"'
335            ),
336            $imgtag
337        );
338
339        return $imgtag;
340    }
341
342    /**
343     * Exports configuration settings to $JSINFO
344     */
345    function _exportToJSINFO(&$event)
346    {
347        global $JSINFO;
348
349        $JSINFO['plugin_preservefilenames'] = array(
350            'in_mediamanager' => true,
351        );
352    }
353
354    /**
355     * Shows a list of media
356     */
357    function _showMediaList(&$event)
358    {
359        global $NS;
360        global $AUTH;
361        global $JUMPTO;
362
363        if ($event->data['do'] !== 'filelist') {
364            return;
365        }
366
367        $event->preventDefault();
368
369        ptln('<div id="media__content">');
370        $this->_listMedia($NS, $AUTH, $JUMPTO);
371        ptln('</div>');
372    }
373
374    /**
375     * Shows a list of media via ajax
376     */
377    function _showMediaListAjax(&$event)
378    {
379        global $JUMPTO;
380
381        if ($event->data !== 'medialist_preservefilenames') {
382            return;
383        }
384
385        $event->preventDefault();
386
387        require_once(DOKU_INC . 'inc/media.php');
388        $ns = cleanID($_POST['ns']);
389        $auth = auth_quickaclcheck("$ns:*");
390
391        $this->_listMedia($ns, $auth, $JUMPTO);
392    }
393
394    /**
395     * Outputs a list of files for mediamanager
396     *
397     * @see media_filelist() in inc/media.php
398     */
399    function _listMedia($ns, $auth, $jumpto)
400    {
401        global $conf;
402        global $lang;
403
404        print '<h1 id="media__ns">:' . hsc($ns) . '</h1>' . NL;
405
406        if ($auth < AUTH_READ) {
407            print '<div class="nothing">' . $lang['nothingfound'] . '</div>' . NL;
408        } else {
409            media_uploadform($ns, $auth);
410
411            $dir = utf8_encodeFN(str_replace(':', '/', $ns));
412            $data = array();
413            search($data, $conf['mediadir'], 'search_media',
414                    array('showmsg' => true, 'depth' => 1), $dir);
415
416            if (empty($data)) {
417                print '<div class="nothing">' . $lang['nothingfound'] . '</div>' . NL;
418            } else {
419                foreach ($data as $item) {
420                    $filename = $this->_getOriginalFileName($item['id']);
421
422                    if ($filename !== false) {
423                        $item['file'] = utf8_encodeFN($filename);
424                    }
425
426                    media_printfile($item, $auth, $jumpto);
427                }
428            }
429        }
430
431        media_searchform($ns);
432    }
433
434    /**
435     * Returns original filename if exists
436     */
437    function _getOriginalFileName($id)
438    {
439        $meta = unserialize(io_readFile(metaFN($id, '.filename'), false));
440        return empty($meta['filename']) ? false : $this->common->_sanitizeFileName($meta['filename']);
441    }
442
443    /**
444     * Replaces the default snippet download handler
445     *
446     * NOTE: This method is needed to fix basename() bug in determining
447     *       filename of the snippet. For more details please see
448     *       common->_correctBasename().
449     */
450    function _replaceSnippetDownload(&$event)
451    {
452        global $ACT;
453
454        // $ACT is not clean, but in most cases this works fine
455        if ($ACT === 'export_code') $ACT = 'export_preservefilenames';
456    }
457}
458