xref: /plugin/calendar/classes/FileHandler.php (revision 2866e8271e4daef3b32eacb3a9082d02159b592b)
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