1815440faSAtari911<?php 2815440faSAtari911/** 3815440faSAtari911 * Calendar Plugin - File Handler 4815440faSAtari911 * 5815440faSAtari911 * Provides atomic file operations with locking to prevent data corruption 6815440faSAtari911 * from concurrent writes. This addresses the critical race condition issue 7815440faSAtari911 * where simultaneous event saves could corrupt JSON files. 8815440faSAtari911 * 9815440faSAtari911 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 10815440faSAtari911 * @author DokuWiki Community 11*2866e827SAtari911 * @version 7.2.6 12815440faSAtari911 */ 13815440faSAtari911 14815440faSAtari911if (!defined('DOKU_INC')) die(); 15815440faSAtari911 16815440faSAtari911class CalendarFileHandler { 17815440faSAtari911 18815440faSAtari911 /** @var int Lock timeout in seconds */ 19815440faSAtari911 private const LOCK_TIMEOUT = 10; 20815440faSAtari911 21815440faSAtari911 /** @var int Maximum retry attempts for acquiring lock */ 22815440faSAtari911 private const MAX_RETRIES = 50; 23815440faSAtari911 24815440faSAtari911 /** @var int Microseconds to wait between lock attempts */ 25815440faSAtari911 private const RETRY_DELAY = 100000; // 100ms 26815440faSAtari911 27815440faSAtari911 /** 28815440faSAtari911 * Read and decode a JSON file safely 29815440faSAtari911 * 30815440faSAtari911 * @param string $filepath Path to JSON file 31815440faSAtari911 * @return array Decoded array or empty array on error 32815440faSAtari911 */ 33815440faSAtari911 public static function readJson($filepath) { 34815440faSAtari911 if (!file_exists($filepath)) { 35815440faSAtari911 return []; 36815440faSAtari911 } 37815440faSAtari911 38815440faSAtari911 $handle = @fopen($filepath, 'r'); 39815440faSAtari911 if (!$handle) { 40815440faSAtari911 self::logError("Failed to open file for reading: $filepath"); 41815440faSAtari911 return []; 42815440faSAtari911 } 43815440faSAtari911 44815440faSAtari911 // Acquire shared lock for reading 45815440faSAtari911 $locked = false; 46815440faSAtari911 for ($i = 0; $i < self::MAX_RETRIES; $i++) { 47815440faSAtari911 if (flock($handle, LOCK_SH | LOCK_NB)) { 48815440faSAtari911 $locked = true; 49815440faSAtari911 break; 50815440faSAtari911 } 51815440faSAtari911 usleep(self::RETRY_DELAY); 52815440faSAtari911 } 53815440faSAtari911 54815440faSAtari911 if (!$locked) { 55815440faSAtari911 fclose($handle); 56815440faSAtari911 self::logError("Failed to acquire read lock: $filepath"); 57815440faSAtari911 return []; 58815440faSAtari911 } 59815440faSAtari911 60815440faSAtari911 $contents = ''; 61815440faSAtari911 while (!feof($handle)) { 62815440faSAtari911 $contents .= fread($handle, 8192); 63815440faSAtari911 } 64815440faSAtari911 65815440faSAtari911 flock($handle, LOCK_UN); 66815440faSAtari911 fclose($handle); 67815440faSAtari911 68815440faSAtari911 if (empty($contents)) { 69815440faSAtari911 return []; 70815440faSAtari911 } 71815440faSAtari911 72815440faSAtari911 $decoded = json_decode($contents, true); 73815440faSAtari911 if (json_last_error() !== JSON_ERROR_NONE) { 74815440faSAtari911 self::logError("JSON decode error in $filepath: " . json_last_error_msg()); 75815440faSAtari911 return []; 76815440faSAtari911 } 77815440faSAtari911 78815440faSAtari911 return is_array($decoded) ? $decoded : []; 79815440faSAtari911 } 80815440faSAtari911 81815440faSAtari911 /** 82815440faSAtari911 * Write data to JSON file atomically with locking 83815440faSAtari911 * 84815440faSAtari911 * Uses a temp file + atomic rename strategy to prevent partial writes. 85815440faSAtari911 * This ensures that the file is never in a corrupted state. 86815440faSAtari911 * 87815440faSAtari911 * @param string $filepath Path to JSON file 88815440faSAtari911 * @param array $data Data to encode and write 89815440faSAtari911 * @return bool Success status 90815440faSAtari911 */ 91815440faSAtari911 public static function writeJson($filepath, array $data) { 92815440faSAtari911 $dir = dirname($filepath); 93815440faSAtari911 if (!is_dir($dir)) { 94815440faSAtari911 if (!@mkdir($dir, 0755, true)) { 95815440faSAtari911 self::logError("Failed to create directory: $dir"); 96815440faSAtari911 return false; 97815440faSAtari911 } 98815440faSAtari911 } 99815440faSAtari911 100815440faSAtari911 // Create temp file in same directory (ensures same filesystem for rename) 101815440faSAtari911 $tempFile = $dir . '/.tmp_' . uniqid() . '_' . basename($filepath); 102815440faSAtari911 103815440faSAtari911 // Encode with pretty print for debugging 104815440faSAtari911 $json = json_encode($data, JSON_PRETTY_PRINT); 105815440faSAtari911 if ($json === false) { 106815440faSAtari911 self::logError("JSON encode error: " . json_last_error_msg()); 107815440faSAtari911 return false; 108815440faSAtari911 } 109815440faSAtari911 110815440faSAtari911 // Write to temp file 111815440faSAtari911 $handle = @fopen($tempFile, 'w'); 112815440faSAtari911 if (!$handle) { 113815440faSAtari911 self::logError("Failed to create temp file: $tempFile"); 114815440faSAtari911 return false; 115815440faSAtari911 } 116815440faSAtari911 117815440faSAtari911 // Acquire exclusive lock on temp file 118815440faSAtari911 if (!flock($handle, LOCK_EX)) { 119815440faSAtari911 fclose($handle); 120815440faSAtari911 @unlink($tempFile); 121815440faSAtari911 self::logError("Failed to lock temp file: $tempFile"); 122815440faSAtari911 return false; 123815440faSAtari911 } 124815440faSAtari911 125815440faSAtari911 $written = fwrite($handle, $json); 126815440faSAtari911 fflush($handle); 127815440faSAtari911 flock($handle, LOCK_UN); 128815440faSAtari911 fclose($handle); 129815440faSAtari911 130815440faSAtari911 if ($written === false) { 131815440faSAtari911 @unlink($tempFile); 132815440faSAtari911 self::logError("Failed to write to temp file: $tempFile"); 133815440faSAtari911 return false; 134815440faSAtari911 } 135815440faSAtari911 136815440faSAtari911 // Now we need to lock the target file during rename 137815440faSAtari911 // If target exists, lock it first 138815440faSAtari911 if (file_exists($filepath)) { 139815440faSAtari911 $targetHandle = @fopen($filepath, 'r+'); 140815440faSAtari911 if ($targetHandle) { 141815440faSAtari911 // Try to get exclusive lock 142815440faSAtari911 $locked = false; 143815440faSAtari911 for ($i = 0; $i < self::MAX_RETRIES; $i++) { 144815440faSAtari911 if (flock($targetHandle, LOCK_EX | LOCK_NB)) { 145815440faSAtari911 $locked = true; 146815440faSAtari911 break; 147815440faSAtari911 } 148815440faSAtari911 usleep(self::RETRY_DELAY); 149815440faSAtari911 } 150815440faSAtari911 151815440faSAtari911 if (!$locked) { 152815440faSAtari911 fclose($targetHandle); 153815440faSAtari911 @unlink($tempFile); 154815440faSAtari911 self::logError("Failed to lock target file: $filepath"); 155815440faSAtari911 return false; 156815440faSAtari911 } 157815440faSAtari911 158815440faSAtari911 // Atomic rename while holding lock 159815440faSAtari911 $renamed = @rename($tempFile, $filepath); 160815440faSAtari911 161815440faSAtari911 flock($targetHandle, LOCK_UN); 162815440faSAtari911 fclose($targetHandle); 163815440faSAtari911 164815440faSAtari911 if (!$renamed) { 165815440faSAtari911 @unlink($tempFile); 166815440faSAtari911 self::logError("Failed to rename temp to target: $filepath"); 167815440faSAtari911 return false; 168815440faSAtari911 } 169815440faSAtari911 } else { 170815440faSAtari911 // Can't open target, try rename anyway 171815440faSAtari911 if (!@rename($tempFile, $filepath)) { 172815440faSAtari911 @unlink($tempFile); 173815440faSAtari911 self::logError("Failed to rename (no handle): $filepath"); 174815440faSAtari911 return false; 175815440faSAtari911 } 176815440faSAtari911 } 177815440faSAtari911 } else { 178815440faSAtari911 // Target doesn't exist, just rename 179815440faSAtari911 if (!@rename($tempFile, $filepath)) { 180815440faSAtari911 @unlink($tempFile); 181815440faSAtari911 self::logError("Failed to rename new file: $filepath"); 182815440faSAtari911 return false; 183815440faSAtari911 } 184815440faSAtari911 } 185815440faSAtari911 186815440faSAtari911 return true; 187815440faSAtari911 } 188815440faSAtari911 189815440faSAtari911 /** 190815440faSAtari911 * Delete a file safely 191815440faSAtari911 * 192815440faSAtari911 * @param string $filepath Path to file 193815440faSAtari911 * @return bool Success status 194815440faSAtari911 */ 195815440faSAtari911 public static function delete($filepath) { 196815440faSAtari911 if (!file_exists($filepath)) { 197815440faSAtari911 return true; 198815440faSAtari911 } 199815440faSAtari911 200815440faSAtari911 $handle = @fopen($filepath, 'r+'); 201815440faSAtari911 if (!$handle) { 202815440faSAtari911 // Try direct delete 203815440faSAtari911 return @unlink($filepath); 204815440faSAtari911 } 205815440faSAtari911 206815440faSAtari911 // Get exclusive lock before deleting 207815440faSAtari911 $locked = false; 208815440faSAtari911 for ($i = 0; $i < self::MAX_RETRIES; $i++) { 209815440faSAtari911 if (flock($handle, LOCK_EX | LOCK_NB)) { 210815440faSAtari911 $locked = true; 211815440faSAtari911 break; 212815440faSAtari911 } 213815440faSAtari911 usleep(self::RETRY_DELAY); 214815440faSAtari911 } 215815440faSAtari911 216815440faSAtari911 if ($locked) { 217815440faSAtari911 flock($handle, LOCK_UN); 218815440faSAtari911 } 219815440faSAtari911 fclose($handle); 220815440faSAtari911 221815440faSAtari911 return @unlink($filepath); 222815440faSAtari911 } 223815440faSAtari911 224815440faSAtari911 /** 225815440faSAtari911 * Ensure directory exists 226815440faSAtari911 * 227815440faSAtari911 * @param string $dir Directory path 228815440faSAtari911 * @return bool Success status 229815440faSAtari911 */ 230815440faSAtari911 public static function ensureDir($dir) { 231815440faSAtari911 if (is_dir($dir)) { 232815440faSAtari911 return true; 233815440faSAtari911 } 234815440faSAtari911 return @mkdir($dir, 0755, true); 235815440faSAtari911 } 236815440faSAtari911 237815440faSAtari911 /** 238815440faSAtari911 * Read a simple text file 239815440faSAtari911 * 240815440faSAtari911 * @param string $filepath Path to file 241815440faSAtari911 * @param string $default Default value if file doesn't exist 242815440faSAtari911 * @return string File contents or default 243815440faSAtari911 */ 244815440faSAtari911 public static function readText($filepath, $default = '') { 245815440faSAtari911 if (!file_exists($filepath)) { 246815440faSAtari911 return $default; 247815440faSAtari911 } 248815440faSAtari911 $contents = @file_get_contents($filepath); 249815440faSAtari911 return $contents !== false ? $contents : $default; 250815440faSAtari911 } 251815440faSAtari911 252815440faSAtari911 /** 253815440faSAtari911 * Write a simple text file atomically 254815440faSAtari911 * 255815440faSAtari911 * @param string $filepath Path to file 256815440faSAtari911 * @param string $content Content to write 257815440faSAtari911 * @return bool Success status 258815440faSAtari911 */ 259815440faSAtari911 public static function writeText($filepath, $content) { 260815440faSAtari911 $dir = dirname($filepath); 261815440faSAtari911 if (!is_dir($dir)) { 262815440faSAtari911 if (!@mkdir($dir, 0755, true)) { 263815440faSAtari911 return false; 264815440faSAtari911 } 265815440faSAtari911 } 266815440faSAtari911 267815440faSAtari911 $tempFile = $dir . '/.tmp_' . uniqid() . '_' . basename($filepath); 268815440faSAtari911 269815440faSAtari911 if (@file_put_contents($tempFile, $content) === false) { 270815440faSAtari911 return false; 271815440faSAtari911 } 272815440faSAtari911 273815440faSAtari911 if (!@rename($tempFile, $filepath)) { 274815440faSAtari911 @unlink($tempFile); 275815440faSAtari911 return false; 276815440faSAtari911 } 277815440faSAtari911 278815440faSAtari911 return true; 279815440faSAtari911 } 280815440faSAtari911 281815440faSAtari911 /** 282815440faSAtari911 * Log error message 283815440faSAtari911 * 284815440faSAtari911 * @param string $message Error message 285815440faSAtari911 */ 286815440faSAtari911 private static function logError($message) { 287815440faSAtari911 if (defined('CALENDAR_DEBUG') && CALENDAR_DEBUG) { 288815440faSAtari911 error_log("[Calendar FileHandler] $message"); 289815440faSAtari911 } 290815440faSAtari911 } 291815440faSAtari911} 292