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