1<?php 2/** 3 * Calendar Plugin - File Handler 4 * 5 * Provides atomic file operations with locking to prevent data corruption 6 * from concurrent writes. This addresses the critical race condition issue 7 * where simultaneous event saves could corrupt JSON files. 8 * 9 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 10 * @author DokuWiki Community 11 * @version 7.0.8 12 */ 13 14if (!defined('DOKU_INC')) die(); 15 16class CalendarFileHandler { 17 18 /** @var int Lock timeout in seconds */ 19 private const LOCK_TIMEOUT = 10; 20 21 /** @var int Maximum retry attempts for acquiring lock */ 22 private const MAX_RETRIES = 50; 23 24 /** @var int Microseconds to wait between lock attempts */ 25 private const RETRY_DELAY = 100000; // 100ms 26 27 /** 28 * Read and decode a JSON file safely 29 * 30 * @param string $filepath Path to JSON file 31 * @return array Decoded array or empty array on error 32 */ 33 public static function readJson($filepath) { 34 if (!file_exists($filepath)) { 35 return []; 36 } 37 38 $handle = @fopen($filepath, 'r'); 39 if (!$handle) { 40 self::logError("Failed to open file for reading: $filepath"); 41 return []; 42 } 43 44 // Acquire shared lock for reading 45 $locked = false; 46 for ($i = 0; $i < self::MAX_RETRIES; $i++) { 47 if (flock($handle, LOCK_SH | LOCK_NB)) { 48 $locked = true; 49 break; 50 } 51 usleep(self::RETRY_DELAY); 52 } 53 54 if (!$locked) { 55 fclose($handle); 56 self::logError("Failed to acquire read lock: $filepath"); 57 return []; 58 } 59 60 $contents = ''; 61 while (!feof($handle)) { 62 $contents .= fread($handle, 8192); 63 } 64 65 flock($handle, LOCK_UN); 66 fclose($handle); 67 68 if (empty($contents)) { 69 return []; 70 } 71 72 $decoded = json_decode($contents, true); 73 if (json_last_error() !== JSON_ERROR_NONE) { 74 self::logError("JSON decode error in $filepath: " . json_last_error_msg()); 75 return []; 76 } 77 78 return is_array($decoded) ? $decoded : []; 79 } 80 81 /** 82 * Write data to JSON file atomically with locking 83 * 84 * Uses a temp file + atomic rename strategy to prevent partial writes. 85 * This ensures that the file is never in a corrupted state. 86 * 87 * @param string $filepath Path to JSON file 88 * @param array $data Data to encode and write 89 * @return bool Success status 90 */ 91 public static function writeJson($filepath, array $data) { 92 $dir = dirname($filepath); 93 if (!is_dir($dir)) { 94 if (!@mkdir($dir, 0755, true)) { 95 self::logError("Failed to create directory: $dir"); 96 return false; 97 } 98 } 99 100 // Create temp file in same directory (ensures same filesystem for rename) 101 $tempFile = $dir . '/.tmp_' . uniqid() . '_' . basename($filepath); 102 103 // Encode with pretty print for debugging 104 $json = json_encode($data, JSON_PRETTY_PRINT); 105 if ($json === false) { 106 self::logError("JSON encode error: " . json_last_error_msg()); 107 return false; 108 } 109 110 // Write to temp file 111 $handle = @fopen($tempFile, 'w'); 112 if (!$handle) { 113 self::logError("Failed to create temp file: $tempFile"); 114 return false; 115 } 116 117 // Acquire exclusive lock on temp file 118 if (!flock($handle, LOCK_EX)) { 119 fclose($handle); 120 @unlink($tempFile); 121 self::logError("Failed to lock temp file: $tempFile"); 122 return false; 123 } 124 125 $written = fwrite($handle, $json); 126 fflush($handle); 127 flock($handle, LOCK_UN); 128 fclose($handle); 129 130 if ($written === false) { 131 @unlink($tempFile); 132 self::logError("Failed to write to temp file: $tempFile"); 133 return false; 134 } 135 136 // Now we need to lock the target file during rename 137 // If target exists, lock it first 138 if (file_exists($filepath)) { 139 $targetHandle = @fopen($filepath, 'r+'); 140 if ($targetHandle) { 141 // Try to get exclusive lock 142 $locked = false; 143 for ($i = 0; $i < self::MAX_RETRIES; $i++) { 144 if (flock($targetHandle, LOCK_EX | LOCK_NB)) { 145 $locked = true; 146 break; 147 } 148 usleep(self::RETRY_DELAY); 149 } 150 151 if (!$locked) { 152 fclose($targetHandle); 153 @unlink($tempFile); 154 self::logError("Failed to lock target file: $filepath"); 155 return false; 156 } 157 158 // Atomic rename while holding lock 159 $renamed = @rename($tempFile, $filepath); 160 161 flock($targetHandle, LOCK_UN); 162 fclose($targetHandle); 163 164 if (!$renamed) { 165 @unlink($tempFile); 166 self::logError("Failed to rename temp to target: $filepath"); 167 return false; 168 } 169 } else { 170 // Can't open target, try rename anyway 171 if (!@rename($tempFile, $filepath)) { 172 @unlink($tempFile); 173 self::logError("Failed to rename (no handle): $filepath"); 174 return false; 175 } 176 } 177 } else { 178 // Target doesn't exist, just rename 179 if (!@rename($tempFile, $filepath)) { 180 @unlink($tempFile); 181 self::logError("Failed to rename new file: $filepath"); 182 return false; 183 } 184 } 185 186 return true; 187 } 188 189 /** 190 * Delete a file safely 191 * 192 * @param string $filepath Path to file 193 * @return bool Success status 194 */ 195 public static function delete($filepath) { 196 if (!file_exists($filepath)) { 197 return true; 198 } 199 200 $handle = @fopen($filepath, 'r+'); 201 if (!$handle) { 202 // Try direct delete 203 return @unlink($filepath); 204 } 205 206 // Get exclusive lock before deleting 207 $locked = false; 208 for ($i = 0; $i < self::MAX_RETRIES; $i++) { 209 if (flock($handle, LOCK_EX | LOCK_NB)) { 210 $locked = true; 211 break; 212 } 213 usleep(self::RETRY_DELAY); 214 } 215 216 if ($locked) { 217 flock($handle, LOCK_UN); 218 } 219 fclose($handle); 220 221 return @unlink($filepath); 222 } 223 224 /** 225 * Ensure directory exists 226 * 227 * @param string $dir Directory path 228 * @return bool Success status 229 */ 230 public static function ensureDir($dir) { 231 if (is_dir($dir)) { 232 return true; 233 } 234 return @mkdir($dir, 0755, true); 235 } 236 237 /** 238 * Read a simple text file 239 * 240 * @param string $filepath Path to file 241 * @param string $default Default value if file doesn't exist 242 * @return string File contents or default 243 */ 244 public static function readText($filepath, $default = '') { 245 if (!file_exists($filepath)) { 246 return $default; 247 } 248 $contents = @file_get_contents($filepath); 249 return $contents !== false ? $contents : $default; 250 } 251 252 /** 253 * Write a simple text file atomically 254 * 255 * @param string $filepath Path to file 256 * @param string $content Content to write 257 * @return bool Success status 258 */ 259 public static function writeText($filepath, $content) { 260 $dir = dirname($filepath); 261 if (!is_dir($dir)) { 262 if (!@mkdir($dir, 0755, true)) { 263 return false; 264 } 265 } 266 267 $tempFile = $dir . '/.tmp_' . uniqid() . '_' . basename($filepath); 268 269 if (@file_put_contents($tempFile, $content) === false) { 270 return false; 271 } 272 273 if (!@rename($tempFile, $filepath)) { 274 @unlink($tempFile); 275 return false; 276 } 277 278 return true; 279 } 280 281 /** 282 * Log error message 283 * 284 * @param string $message Error message 285 */ 286 private static function logError($message) { 287 if (defined('CALENDAR_DEBUG') && CALENDAR_DEBUG) { 288 error_log("[Calendar FileHandler] $message"); 289 } 290 } 291} 292