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