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