1<?php
2/**
3 * DokuWiki Plugin fetchmedia (Action Component)
4 *
5 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6 * @author  Andreas Gohr, Michael Große <dokuwiki@cosmocode.de>
7 */
8
9// must be run within Dokuwiki
10if(!defined('DOKU_INC')) die();
11
12class action_plugin_fetchmedia_ajax extends DokuWiki_Action_Plugin {
13
14    /**
15     * Registers a callback function for a given event
16     *
17     * @param Doku_Event_Handler $controller DokuWiki's event controller object
18     *
19     * @return void
20     */
21    public function register(Doku_Event_Handler $controller) {
22        $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handle_ajax');
23    }
24
25    /**
26     *
27     *
28     * @param Doku_Event $event  event object by reference
29     * @param mixed      $param  [the parameters passed as fifth argument to register_hook() when this
30     *                           handler was registered]
31     */
32    public function handle_ajax(Doku_Event $event, $param) {
33        $call = 'plugin_fetchmedia';
34        if (0 !== strpos($event->data, $call)) {
35            return;
36        }
37
38        if (!auth_isadmin()) {
39            return;
40        }
41        $event->preventDefault();
42        $event->stopPropagation();
43
44        global $INPUT, $conf;
45        $action = substr($event->data, strlen($call)+1);
46
47        header('Content-Type: application/json');
48        try {
49            $result = $this->executeAjaxAction($action);
50        } catch (Exception $e) {
51            $result = array(
52                'error' => $e->getMessage() . ' ' . basename($e->getFile()) . ':' . $e->getLine()
53            );
54            if ($conf['allowdebug']) {
55                $result['stacktrace'] = $e->getTraceAsString();
56            }
57            http_status(500);
58        }
59
60        if (is_array($result) && isset($result['status'])) {
61            http_status($result['status']);
62        }
63        echo json_encode($result);
64    }
65
66    protected function executeAjaxAction($action) {
67        global $INPUT;
68        switch ($action) {
69            case 'getExternalMediaLinks':
70                $namespace = $INPUT->str('namespace');
71                $type = $INPUT->str('type');
72                return $this->findExternalMediaFiles($namespace, $type);
73            case 'downloadExternalFile':
74                $page = $INPUT->str('page');
75                $link = $INPUT->str('link');
76                return $this->lockAndDownload($page, $link);
77            default:
78                throw new Exception('FIXME invalid action');
79        }
80    }
81
82    protected function lockAndDownload($pageId, $link) {
83        $lock = checklock($pageId);
84        if ($lock !== false) {
85            return ['status' => 409, 'status_text' => sprintf($this->getLang('error: page is locked'), $lock)];
86        }
87        lock($pageId);
88        try {
89            $results = $this->downloadExternalFile($pageId, $link);
90        } catch (Exception $e) {
91            return ['status' => 500, 'status_text' => hsc($e->getMessage())];
92        }
93        unlock($pageId);
94        return $results;
95    }
96
97    protected function downloadExternalFile($pageId, $link) {
98        // check that link is on page
99
100        $fn = $this->constructFileName($link);
101        $id = getNS(cleanID($pageId)) . ':' . $fn;
102
103        // check if file exists
104        if (filter_var($link, FILTER_VALIDATE_URL)) {
105            // check headers
106            $headers = get_headers($link);
107            $statusHeaders = array_filter($headers, function ($elem) {
108                return strpos($elem, ':') === false;
109            });
110            $finalStatus = end($statusHeaders);
111            list($protocoll, $code, $textstatus) = explode(' ', $finalStatus, 3);
112            if ($code >= 400) {
113                return ['status' => $code, 'status_text' => $textstatus];
114            }
115        } else {
116            // windows share
117            if (!file_exists($link)) {
118                return ['status' => 404, 'status_text' => $this->getLang('error: windows share missing')];
119            }
120
121            if (is_dir($link)) {
122                return ['status' => 422, 'status_text' => $this->getLang('error: windows share is directory')];
123            }
124
125            if (!is_readable($link)) {
126                return ['status' => 403, 'status_text' => $this->getLang('error: windows share not readable')];
127            }
128        }
129
130        // download file
131        $res = fopen($link, 'rb');
132        if ($res === false) {
133            return ['status' => 500, 'status_text' => $this->getLang('error: failed to open stream')];
134        }
135        if (!($tmp = io_mktmpdir())) {
136            throw new Exception('Failed to create tempdir');
137        };
138        $path = $tmp.'/'.md5($id);
139        $target = fopen($path, 'wb');
140        $realSize = stream_copy_to_stream($res, $target);
141        fclose($target);
142        fclose($res);
143
144        // check if download was successful
145        if ($realSize === false) {
146            return ['status' => 500, 'status_text' => $this->getLang('error: failed to download file')];
147        }
148
149        list($ext,$mime) = mimetype($id);
150        $file = [
151            'name' => $path,
152            'mime' => $mime,
153            'ext' => $ext,
154        ];
155        $mediaID = media_save($file, $id, false, auth_quickaclcheck($id), 'rename');
156        if (!is_string($mediaID)) {
157            list($textstatus, $code) = $mediaID;
158            return ['status' => 400, 'status_text' => $textstatus];
159        }
160
161        // report status?
162
163        // replace link
164        $text = rawWiki($pageId);
165        $newText = $this->replaceLinkInText($text, $link, $mediaID);
166
167        // create new page revision
168        if ($text !== $newText) {
169            if (filemtime(wikiFN($pageId)) == time()) {
170                $this->waitForTick(true);
171            }
172            saveWikiText($pageId, $newText, 'File ' . hsc($link) . ' downloaded by fetchmedia plugin');
173        }
174
175        // report ok
176        return ['status' => 200, 'status_text' => $mediaID];
177    }
178
179    /**
180     * @param string $text
181     * @param string $oldlink
182     * @param string $newMediaId
183     *
184     * @return string the adjusted text
185     */
186    public function replaceLinkInText($text, $oldlink, $newMediaId) {
187        if (filter_var($oldlink, FILTER_VALIDATE_URL)) {
188            $type = ['externalmedia'];
189        } else {
190            $type = ['windowssharelink'];
191        }
192        $done = false;
193        while (!$done) {
194            $done = true;
195            $ins = p_get_instructions($text);
196            $mediaLinkInstructions = $this->searchInstructions($ins, $type);
197            foreach ($mediaLinkInstructions as $mediaLinkInstruction) {
198                if ($mediaLinkInstruction[1][0] !== $oldlink) {
199                    continue;
200                }
201                $done = false;
202
203                // FIXME: handle spaces for positioning! m(
204
205                $start = $mediaLinkInstruction[2] + 1;
206                $end = $mediaLinkInstruction[2] + 1 + strlen($oldlink);
207                $prefix = substr($text, 0, $start);
208                $postfix = substr($text, $end);
209                if (substr($prefix, -2) === '[[') {
210                    $prefix = substr($prefix, 0, -2) . '{{';
211                    $closingBracketsPos = strpos($postfix, ']]');
212                    $postfix = substr($postfix, 0, $closingBracketsPos) . '}}' . substr($postfix,  $closingBracketsPos + 2);
213                }
214                $text = $prefix . $newMediaId . $postfix;
215                break;
216            }
217        }
218
219        return $text;
220    }
221
222    public function findExternalMediaFiles($namespace, $type) {
223        $pageresults = [];
224        $basedir = dirname(wikiFN(cleanID($namespace) . ':start'));
225        search($pageresults, $basedir, 'search_allpages', []);
226
227        $mediaLinks = [];
228        $instructionNames = [];
229        if ('all' == $type || 'windows-shares' == $type) {
230            $instructionNames[] = 'windowssharelink';
231        }
232        if ('all' == $type || 'common' == $type) {
233            $instructionNames[] = 'externalmedia';
234        }
235        if (empty($instructionNames)) {
236            return [];
237        }
238        foreach ($pageresults as $page) {
239            $pagename = cleanID($namespace) . ':' . $page['id'];
240            $ins = p_cached_instructions(wikiFN($pagename));
241
242            $mediaLinkInstructions = $this->searchInstructions($ins, $instructionNames);
243            $mediaLinks[$pagename] = [];
244            foreach ($mediaLinkInstructions as $mediaLinkInstruction) {
245                if ($mediaLinkInstruction[0] == 'windowssharelink') {
246                    $mediaLinks[$pagename][] = $mediaLinkInstruction[1][0];
247                } elseif (filter_var($mediaLinkInstruction[1][0], FILTER_VALIDATE_URL)) {
248                    $mediaLinks[$pagename][] = $mediaLinkInstruction[1][0];
249                }
250            }
251            $mediaLinks[$pagename] = array_unique($mediaLinks[$pagename]); // ensure we have no duplicates
252            $mediaLinks[$pagename] = array_values($mediaLinks[$pagename]); // ensure that the array is correctly numbered 0,1,2,...
253        }
254
255        $mediaLinks = array_filter($mediaLinks);
256
257        return $mediaLinks;
258    }
259
260
261    /**
262     * FIXME: ensure that this also catches media-links
263     *
264     * @param $instructions
265     * @param $searchString
266     *
267     * @return array
268     */
269    protected function searchInstructions($instructions, array $searchStringList) {
270        $results = [];
271        foreach ($instructions as $instruction) {
272            if (in_array($instruction[0], $searchStringList)) {
273                $results[] = $instruction;
274            }
275        }
276        return $results;
277    }
278
279    /**
280     * @param $link
281     *
282     * @return string
283     */
284    public function constructFileName($link) {
285        $urlFNstart = strrpos($link, '/') + 1;
286        $windosFNstart = strrpos($link, '\\') + 1;
287        $fnStart = max($urlFNstart, $windosFNstart);
288        return substr($link, $fnStart);
289    }
290
291    /**
292     * Waits until a new second has passed
293     *
294     * The very first call will return immeadiately, proceeding calls will return
295     * only after at least 1 second after the last call has passed.
296     *
297     * When passing $init=true it will not return immeadiately but use the current
298     * second as initialization. It might still return faster than a second.
299     *
300     * This is a duplicate of the code in @see \DokuWikiTest::waitForTick
301     *
302     * @param bool $init wait from now on, not from last time
303     * @return int new timestamp
304     */
305    protected function waitForTick($init = false) {
306        static $last = 0;
307        if($init) $last = time();
308        while($last === $now = time()) {
309            usleep(100000); //recheck in a 10th of a second
310        }
311        $last = $now;
312        return $now;
313    }
314}
315