1<?php 2 3use dokuwiki\Extension\ActionPlugin; 4use dokuwiki\Extension\EventHandler; 5use dokuwiki\Extension\Event; 6 7/** 8 * DokuWiki Plugin Mizar Verifiable Docs (Action Component) 9 * 10 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 11 * @author 12 */ 13 14class action_plugin_mizarverifiabledocs extends ActionPlugin 15{ 16 /** 17 * Registers a callback function for a given event 18 * 19 * @param EventHandler $controller DokuWiki's event controller object 20 * @return void 21 */ 22 public function register(EventHandler $controller) 23 { 24 $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handle_ajax_call'); 25 } 26 27 /** 28 * Handles AJAX requests 29 * 30 * @param Event $event 31 * @param $param 32 */ 33 public function handle_ajax_call(Event $event, $param) 34 { 35 unset($param); // 未使用のパラメータを無効化 36 37 switch ($event->data) { 38 case 'clear_temp_files': 39 $event->preventDefault(); 40 $event->stopPropagation(); 41 $this->clearTempFiles(); 42 break; 43 case 'source_sse': 44 $event->preventDefault(); 45 $event->stopPropagation(); 46 $this->handleSourceSSERequest(); 47 break; 48 case 'source_compile': 49 $event->preventDefault(); 50 $event->stopPropagation(); 51 $this->handleSourceCompileRequest(); 52 break; 53 case 'view_compile': 54 $event->preventDefault(); 55 $event->stopPropagation(); 56 $this->handleViewCompileRequest(); 57 break; 58 case 'view_sse': 59 $event->preventDefault(); 60 $event->stopPropagation(); 61 $this->handleViewSSERequest(); 62 break; 63 case 'create_combined_file': 64 $event->preventDefault(); 65 $event->stopPropagation(); 66 $this->handle_create_combined_file(); 67 break; 68 } 69 } 70 71 // source用のコンパイルリクエスト処理 72 private function handleSourceCompileRequest() 73 { 74 global $INPUT; 75 $pageContent = $INPUT->post->str('content'); 76 $mizarData = $this->extractMizarContent($pageContent); 77 78 // エラーチェックを追加 79 if ($mizarData === null) { 80 $this->sendAjaxResponse(false, 'Mizar content not found'); 81 return; 82 } elseif (isset($mizarData['error'])) { 83 $this->sendAjaxResponse(false, $mizarData['error']); 84 return; 85 } 86 87 $filePath = $this->saveMizarContent($mizarData); 88 89 session_start(); 90 $_SESSION['source_filepath'] = $filePath; 91 92 $this->sendAjaxResponse(true, 'Mizar content processed successfully'); 93 } 94 95 // source用のSSEリクエスト処理 96 private function handleSourceSSERequest() 97 { 98 header('Content-Type: text/event-stream'); 99 header('Cache-Control: no-cache'); 100 101 session_start(); 102 if (!isset($_SESSION['source_filepath'])) { 103 echo "data: Mizar file path not found in session\n\n"; 104 ob_flush(); 105 flush(); 106 return; 107 } 108 109 $filePath = $_SESSION['source_filepath']; 110 $this->streamSourceOutput($filePath); 111 112 echo "event: end\n"; 113 echo "data: Compilation complete\n\n"; 114 ob_flush(); 115 flush(); 116 } 117 118 // view用のコンパイルリクエスト処理 119 private function handleViewCompileRequest() 120 { 121 global $INPUT; 122 $content = $INPUT->post->str('content'); 123 124 $filePath = $this->createTempFile($content); 125 126 session_start(); 127 $_SESSION['view_filepath'] = $filePath; 128 129 $this->sendAjaxResponse(true, 'Mizar content processed successfully'); 130 } 131 132 // view用のSSEリクエスト処理 133 private function handleViewSSERequest() 134 { 135 header('Content-Type: text/event-stream'); 136 header('Cache-Control: no-cache'); 137 138 session_start(); 139 if (!isset($_SESSION['view_filepath'])) { 140 echo "data: Mizar file path not found in session\n\n"; 141 ob_flush(); 142 flush(); 143 return; 144 } 145 146 $filePath = $_SESSION['view_filepath']; 147 $this->streamViewCompileOutput($filePath); 148 149 echo "event: end\n"; 150 echo "data: Compilation complete\n\n"; 151 ob_flush(); 152 flush(); 153 } 154 155 // Mizarコンテンツの抽出 156 private function extractMizarContent($pageContent) 157 { 158 $pattern = '/<mizar\s+([^>]+)>(.*?)<\/mizar>/s'; 159 preg_match_all($pattern, $pageContent, $matches, PREG_SET_ORDER); 160 161 if (empty($matches)) { 162 return null; 163 } 164 165 // 最初のファイル名を取得し、拡張子を除去 166 $fileName = trim($matches[0][1]); 167 $fileNameWithoutExt = preg_replace('/\.miz$/i', '', $fileName); 168 169 // ファイル名のバリデーションを追加 170 if (!$this->isValidFileName($fileNameWithoutExt)) { 171 return ['error' => "Invalid characters in file name: '{$fileNameWithoutExt}'. Only letters, numbers, underscores (_), and apostrophes (') are allowed, up to 8 characters."]; 172 } 173 174 $combinedContent = ''; 175 176 foreach ($matches as $match) { 177 $currentFileName = trim($match[1]); 178 $currentFileNameWithoutExt = preg_replace('/\.miz$/i', '', $currentFileName); 179 180 if ($currentFileNameWithoutExt !== $fileNameWithoutExt) { 181 return ['error' => "File name mismatch in <mizar> tags: '{$fileNameWithoutExt}' and '{$currentFileNameWithoutExt}'"]; 182 } 183 184 // バリデーションを各ファイル名にも適用 185 if (!$this->isValidFileName($currentFileNameWithoutExt)) { 186 return ['error' => "Invalid characters in file name: '{$currentFileNameWithoutExt}'. Only letters, numbers, underscores (_), and apostrophes (') are allowed, up to 8 characters."]; 187 } 188 189 $combinedContent .= trim($match[2]) . "\n"; 190 } 191 192 // ファイル名に拡張子を付加 193 $fullFileName = $fileNameWithoutExt . '.miz'; 194 195 return ['fileName' => $fullFileName, 'content' => $combinedContent]; 196 } 197 198 // ファイル名のバリデーション関数を追加 199 private function isValidFileName($fileName) 200 { 201 // ファイル名の長さをチェック(最大8文字) 202 if (strlen($fileName) > 8) { 203 return false; 204 } 205 206 // 許可される文字のみを含むかチェック 207 if (!preg_match('/^[A-Za-z0-9_\']+$/', $fileName)) { 208 return false; 209 } 210 211 return true; 212 } 213 214 // Mizarコンテンツの保存 215 private function saveMizarContent($mizarData) 216 { 217 $workPath = rtrim($this->getConf('mizar_work_dir'), '/\\'); 218 $filePath = $workPath . "/TEXT/" . $mizarData['fileName']; 219 file_put_contents($filePath, $mizarData['content']); 220 return $filePath; 221 } 222 223 // source用の出力をストリーム 224 private function streamSourceOutput($filePath) 225 { 226 $workPath = rtrim($this->getConf('mizar_work_dir'), '/\\'); 227 chdir($workPath); 228 229 $command = "miz2prel " . escapeshellarg($filePath); 230 $process = proc_open($command, array(1 => array("pipe", "w")), $pipes); 231 232 if (is_resource($process)) { 233 while ($line = fgets($pipes[1])) { 234 echo "data: " . $line . "\n\n"; 235 ob_flush(); 236 flush(); 237 } 238 fclose($pipes[1]); 239 240 // エラー処理の追加 241 $errFilename = str_replace('.miz', '.err', $filePath); 242 if ($this->handleCompilationErrors($errFilename, rtrim($this->getConf('mizar_share_dir'), '/\\') . '/mizar.msg')) { 243 // エラーがあった場合は処理を終了 244 proc_close($process); 245 return; 246 } 247 248 proc_close($process); 249 } 250 } 251 252 // view用の一時ファイル作成 253 private function createTempFile($content) 254 { 255 $workPath = rtrim($this->getConf('mizar_work_dir'), '/\\') . '/TEXT/'; 256 $uniqueName = str_replace('.', '_', uniqid('tmp', true)); 257 $tempFilename = $workPath . $uniqueName . ".miz"; 258 file_put_contents($tempFilename, $content); 259 return $tempFilename; 260 } 261 262 // 一時ファイルの削除 263 private function clearTempFiles() 264 { 265 $workPath = rtrim($this->getConf('mizar_work_dir'), '/\\') . '/TEXT/'; 266 $files = glob($workPath . '*'); // TEXTフォルダ内のすべてのファイルを取得 267 268 $errors = []; 269 foreach ($files as $file) { 270 if (is_file($file)) { 271 // ファイルが使用中かどうか確認 272 if (!$this->is_file_locked($file)) { 273 $retries = 3; // 最大3回リトライ 274 while ($retries > 0) { 275 if (unlink($file)) { 276 break; // 削除成功 277 } 278 $errors[] = "Error deleting $file: " . error_get_last()['message']; 279 $retries--; 280 sleep(1); // 1秒待ってリトライ 281 } 282 if ($retries === 0) { 283 $errors[] = "Failed to delete: $file"; // 削除失敗 284 } 285 } else { 286 $errors[] = "File is locked: $file"; // ファイルがロックされている 287 } 288 } 289 } 290 291 if (empty($errors)) { 292 $this->sendAjaxResponse(true, 'Temporary files cleared successfully'); 293 } else { 294 $this->sendAjaxResponse(false, 'Some files could not be deleted', $errors); 295 } 296 } 297 298 // ファイルがロックされているかをチェックする関数 299 private function is_file_locked($file) 300 { 301 $fileHandle = @fopen($file, "r+"); 302 303 if ($fileHandle === false) { 304 return true; // ファイルが開けない、つまりロックされているかアクセス権がない 305 } 306 307 $locked = !flock($fileHandle, LOCK_EX | LOCK_NB); // ロックの取得を試みる(非ブロッキングモード) 308 309 fclose($fileHandle); 310 return $locked; // ロックが取得できなければファイルはロックされている 311 } 312 313 // View用コンパイル出力のストリーム 314 private function streamViewCompileOutput($filePath) 315 { 316 $workPath = $this->getConf('mizar_work_dir'); 317 $sharePath = rtrim($this->getConf('mizar_share_dir'), '/\\') . '/'; 318 319 chdir($workPath); 320 321 $errFilename = str_replace('.miz', '.err', $filePath); 322 $command = "makeenv " . escapeshellarg($filePath); 323 $process = proc_open($command, array(1 => array("pipe", "w"), 2 => array("pipe", "w")), $pipes); 324 325 if (is_resource($process)) { 326 while ($line = fgets($pipes[1])) { 327 echo "data: " . mb_convert_encoding($line, 'UTF-8', 'SJIS') . "\n\n"; 328 ob_flush(); 329 flush(); 330 } 331 fclose($pipes[1]); 332 333 while ($line = fgets($pipes[2])) { 334 echo "data: ERROR: " . mb_convert_encoding($line, 'UTF-8', 'SJIS') . "\n\n"; 335 ob_flush(); 336 flush(); 337 } 338 fclose($pipes[2]); 339 proc_close($process); 340 341 // makeenvのエラー処理 342 if ($this->handleCompilationErrors($errFilename, $sharePath . '/mizar.msg')) { 343 return; 344 } 345 346 // verifierの実行 347 $exePath = rtrim($this->getConf('mizar_exe_dir'), '/\\') . '/'; 348 $verifierPath = escapeshellarg($exePath . "verifier"); 349 if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { 350 $verifierPath .= ".exe"; 351 } 352 $verifierCommand = $verifierPath . " -q -l " . escapeshellarg("TEXT/" . basename($filePath)); 353 354 $verifierProcess = proc_open($verifierCommand, array(1 => array("pipe", "w"), 2 => array("pipe", "w")), $verifierPipes); 355 356 if (is_resource($verifierProcess)) { 357 while ($line = fgets($verifierPipes[1])) { 358 echo "data: " . mb_convert_encoding($line, 'UTF-8', 'SJIS') . "\n\n"; 359 ob_flush(); 360 flush(); 361 } 362 fclose($verifierPipes[1]); 363 364 while ($line = fgets($verifierPipes[2])) { 365 echo "data: ERROR: " . mb_convert_encoding($line, 'UTF-8', 'SJIS') . "\n\n"; 366 ob_flush(); 367 flush(); 368 } 369 fclose($verifierPipes[2]); 370 proc_close($verifierProcess); 371 372 // verifierのエラー処理 373 if ($this->handleCompilationErrors($errFilename, $sharePath . '/mizar.msg')) { 374 return; 375 } 376 } else { 377 echo "data: ERROR: Failed to execute verifier command.\n\n"; 378 ob_flush(); 379 flush(); 380 } 381 } else { 382 echo "data: ERROR: Failed to execute makeenv command.\n\n"; 383 ob_flush(); 384 flush(); 385 } 386 } 387 388 private function getMizarErrorMessages($mizarMsgFile) 389 { 390 $errorMessages = []; 391 $lines = file($mizarMsgFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); 392 393 $isReadingErrorMsg = false; 394 $key = 0; 395 396 foreach ($lines as $line) { 397 if (preg_match('/# (\d+)/', $line, $matches)) { 398 $isReadingErrorMsg = true; 399 $key = intval($matches[1]); 400 } elseif ($isReadingErrorMsg) { 401 $errorMessages[$key] = $line; 402 $isReadingErrorMsg = false; 403 } 404 } 405 406 return $errorMessages; 407 } 408 409 private function sendAjaxResponse($success, $message, $data = '') 410 { 411 header('Content-Type: application/json'); 412 echo json_encode(['success' => $success, 'message' => $message, 'data' => $data]); 413 exit; 414 } 415 416 private function handleCompilationErrors($errFilename, $mizarMsgFilePath) 417 { 418 if (file_exists($errFilename)) { 419 $errors = []; 420 $errorLines = file($errFilename, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); 421 foreach ($errorLines as $errorLine) { 422 if (preg_match('/(\d+)\s+(\d+)\s+(\d+)/', $errorLine, $matches)) { 423 $errorCode = intval($matches[3]); 424 $errors[] = [ 425 'code' => $errorCode, 426 'line' => intval($matches[1]), 427 'column' => intval($matches[2]), 428 'message' => $this->getMizarErrorMessages($mizarMsgFilePath)[$errorCode] ?? 'Unknown error' 429 ]; 430 } 431 } 432 if (!empty($errors)) { 433 echo "event: compileErrors\n"; 434 echo "data: " . json_encode($errors) . "\n\n"; 435 ob_flush(); 436 flush(); 437 return true; 438 } 439 } 440 return false; 441 } 442 443 private function handle_create_combined_file() 444 { 445 global $INPUT; 446 447 // 投稿されたコンテンツを取得 448 $combinedContent = $INPUT->post->str('content'); 449 $filename = $INPUT->post->str('filename', 'combined_file.miz'); // デフォルトのファイル名を指定 450 451 // ファイルを保存せず、コンテンツを直接返す 452 if (!empty($combinedContent)) { 453 // ファイルの内容をレスポンスで返す(PHP側でファイルを作成しない) 454 $this->sendAjaxResponse(true, 'File created successfully', [ 455 'filename' => $filename, 456 'content' => $combinedContent 457 ]); 458 error_log("File content sent: " . $filename); 459 } else { 460 $this->sendAjaxResponse(false, 'Content is empty, no file created'); 461 } 462 } 463} 464