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