PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / core / FileSystemService.php

FileSystemService.php in 404 Solution 4.3.0, at includes/core/FileSystemService.php

416 lines 14.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /**
9 * File and directory I/O for the plugin: reading SQL templates and HTML
10 * fragments, writing remote-downloaded payloads, and removing temp dirs.
11 *
12 * Extracted from ABJ_404_Solution_Functions per design-audit-2026-06-02
13 * M201 (Functions.php grab-bag split). All methods are static and have
14 * no dependency on the polymorphic mbstring adapter base class.
15 *
16 * Survival of transient stream-wrapper warnings (under Patchwork during
17 * heavy ParaTest parallelism) is the responsibility of
18 * fileGetContentsWithTransientRetry(), which retries up to
19 * FILE_READ_MAX_ATTEMPTS on EINTR-style "Interrupted system call"
20 * warnings before giving up and falling through to the curl fallback.
21 */
22 class ABJ_404_Solution_FileSystemService {
23
24 private const FILE_READ_MAX_ATTEMPTS = 3;
25 private const FILE_READ_RETRY_BASE_US = 10000;
26
27 /** Returns true if the file does not exist after calling this method.
28 * @param string $path
29 * @return boolean
30 */
31 static function safeUnlink($path) {
32 if (!file_exists($path)) {
33 return true;
34 }
35
36 $unlinkError = null;
37 set_error_handler(static function($errno, $errstr) use (&$unlinkError) {
38 $unlinkError = (string)$errstr;
39 return true;
40 });
41 try {
42 $result = unlink($path);
43 } finally {
44 restore_error_handler();
45 }
46 if ($result !== false) {
47 return true;
48 }
49
50 clearstatcache(true, (string)$path);
51 if (!file_exists($path)) {
52 return true;
53 }
54
55 $reason = $unlinkError !== null
56 ? $unlinkError
57 : 'unknown unlink failure';
58 self::logWarning('Unable to unlink file: ' . (string)$path . ' (' . $reason . ')');
59 return false;
60 }
61
62 /** Returns true if the file does not exist after calling this method.
63 * @param string $path
64 * @return boolean
65 */
66 static function safeRmdir($path) {
67 if (file_exists($path)) {
68 return rmdir($path);
69 }
70 return true;
71 }
72
73 /** Recursively delete a directory.
74 * @param string $dir
75 * @throws Exception
76 * @return boolean
77 */
78 static function deleteDirectoryRecursively($dir) {
79 // if the directory isn't a part of our plugin then don't do it.
80 if (strpos($dir, ABJ404_PATH) === false) {
81 throw new Exception("Can't delete " . esc_html($dir));
82 }
83
84 // if it's already gone then we're done.
85 if (!file_exists($dir)) {
86 return true;
87 }
88
89 // if it's not a directory then delete the file.
90 if (!is_dir($dir)) {
91 return unlink($dir);
92 }
93
94 // get a list of all files (and directories) in the directory.
95 $items = scandir($dir);
96 if (!is_array($items)) { $items = array(); }
97 foreach ($items as $item) {
98 if ($item == '.' || $item == '..') {
99 continue;
100 }
101
102 // call self to delete the file/directory.
103 if (!self::deleteDirectoryRecursively($dir . DIRECTORY_SEPARATOR . $item)) {
104 return false;
105 }
106
107 }
108
109 // remove the original directory.
110 return rmdir($dir);
111 }
112
113 /**
114 * Create a directory (recursively), logging error_get_last() reasons on
115 * failure. Returns true if the directory exists when this method
116 * returns, false otherwise.
117 *
118 * @param string $directory
119 * @return boolean
120 */
121 static function createDirectoryWithErrorMessages($directory) {
122 if (!is_dir($directory)) {
123 if (file_exists($directory) || file_exists(rtrim($directory, '/'))) {
124 $unlinkErr = null;
125 if (!@unlink($directory)) {
126 $lastErr = error_get_last();
127 $unlinkErr = is_array($lastErr) ? $lastErr['message'] : 'unknown';
128 }
129
130 if (file_exists($directory) || file_exists(rtrim($directory, '/'))) {
131 self::logWarning("Error creating the directory " .
132 $directory . ". A file with that name already exists" .
133 ($unlinkErr !== null ? " and unlink() failed: " . $unlinkErr : "") .
134 ". Action: aborting directory creation, returning false.");
135 return false;
136 }
137
138 } else if (!@mkdir($directory, 0755, true)) {
139 $lastErr = error_get_last();
140 $mkdirErr = is_array($lastErr) ? $lastErr['message'] : 'unknown';
141 self::logWarning("Error creating the directory " .
142 $directory . ". mkdir() failed: " . $mkdirErr .
143 ". Action: aborting directory creation, returning false.");
144 return false;
145 }
146 }
147 return true;
148 }
149
150 /** Reads an entire file at once into a string and return it.
151 * @param string $path
152 * @param boolean $appendExtraData
153 * @throws Exception
154 * @return string
155 */
156 static function readFileContents($path, $appendExtraData = true) {
157 // modify what's returned to make debugging easier.
158 $dataSupplement = self::getDataSupplement($path, $appendExtraData);
159
160 if (!file_exists($path)) {
161 throw new Exception("Error: Can't find file: " . esc_html($path));
162 }
163
164 $readResult = self::fileGetContentsWithTransientRetry($path);
165 $fileContents = $readResult['contents'];
166 if ($fileContents !== false) {
167 if (!empty($readResult['warnings'])) {
168 self::logWarning(
169 'readFileContents recovered after transient file-open failure for '
170 . $path . '. ' . self::formatFileReadWarnings($readResult['warnings'])
171 );
172 }
173 return $dataSupplement['prefix'] . $fileContents . $dataSupplement['suffix'];
174 }
175
176 $warningDetails = self::formatFileReadWarnings($readResult['warnings']);
177
178 // if we can't read the file that way then try curl.
179 if (!function_exists('curl_init')) {
180 throw new Exception("Error: Can't read file: " . esc_html($path) .
181 "\n file_get_contents didn't work and curl is not installed." . $warningDetails);
182 }
183 $ch = curl_init();
184 curl_setopt($ch, CURLOPT_URL, 'file://' . $path);
185 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
186 $output = curl_exec($ch);
187
188 if ($output == null) {
189 throw new Exception("Error: Can't read file, even with cURL: " . esc_html($path) . $warningDetails);
190 }
191
192 if ($warningDetails !== '') {
193 self::logWarning(
194 'readFileContents used cURL fallback after file_get_contents failed for '
195 . $path . '. ' . $warningDetails
196 );
197 }
198
199 return $dataSupplement['prefix'] . $output . $dataSupplement['suffix'];
200 }
201
202 /**
203 * Reads a file while retrying only the transient EINTR-style failures
204 * emitted by Patchwork's stream wrapper under high ParaTest load.
205 *
206 * @param string $path
207 * @return array{contents:string|false,warnings:array<int,array{errno:int,message:string,file:string,line:int}>}
208 */
209 private static function fileGetContentsWithTransientRetry($path): array {
210 $warnings = array();
211
212 for ($attempt = 1; $attempt <= self::FILE_READ_MAX_ATTEMPTS; $attempt++) {
213 $attemptWarnings = array();
214
215 set_error_handler(
216 static function(int $errno, string $errstr, string $errfile = '', int $errline = 0) use (&$attemptWarnings): bool {
217 if (($errno & (E_WARNING | E_USER_WARNING)) === 0) {
218 return false;
219 }
220 $attemptWarnings[] = array(
221 'errno' => $errno,
222 'message' => $errstr,
223 'file' => $errfile,
224 'line' => $errline,
225 );
226 return true;
227 },
228 E_WARNING | E_USER_WARNING
229 );
230 try {
231 $contents = file_get_contents($path);
232 } finally {
233 restore_error_handler();
234 }
235
236 if ($contents !== false) {
237 return array(
238 'contents' => $contents,
239 'warnings' => array_merge($warnings, $attemptWarnings),
240 );
241 }
242
243 $warnings = array_merge($warnings, $attemptWarnings);
244 if (!self::isTransientFileReadWarning($attemptWarnings)) {
245 break;
246 }
247
248 if ($attempt < self::FILE_READ_MAX_ATTEMPTS) {
249 usleep(self::FILE_READ_RETRY_BASE_US * $attempt);
250 }
251 }
252
253 return array(
254 'contents' => false,
255 'warnings' => $warnings,
256 );
257 }
258
259 /**
260 * @param array<int,array{errno:int,message:string,file:string,line:int}> $warnings
261 * @return bool
262 */
263 private static function isTransientFileReadWarning(array $warnings): bool {
264 foreach ($warnings as $warning) {
265 if (strpos(strtolower($warning['message']), 'interrupted system call') !== false) {
266 return true;
267 }
268 }
269 return false;
270 }
271
272 /**
273 * @param array<int,array{errno:int,message:string,file:string,line:int}> $warnings
274 * @return string
275 */
276 private static function formatFileReadWarnings(array $warnings): string {
277 if (empty($warnings)) {
278 return '';
279 }
280
281 $parts = array();
282 foreach ($warnings as $warning) {
283 $parts[] = sprintf(
284 '[%d] %s in %s:%d',
285 $warning['errno'],
286 $warning['message'],
287 $warning['file'],
288 $warning['line']
289 );
290 }
291
292 return "\n file_get_contents warnings: " . implode(' | ', $parts);
293 }
294
295 /**
296 * Build the BEGIN/END banner pair that brackets file contents in the
297 * debug-log dump so an admin reading a debug bundle can see where each
298 * embedded artifact starts and ends. SQL gets /* ... *\/ comments;
299 * HTML gets HTML comments; other types get a generic marker.
300 *
301 * @param string $filePath
302 * @param bool $appendExtraData
303 * @return array<string, string>
304 */
305 private static function getDataSupplement(string $filePath, bool $appendExtraData = true): array {
306 $path = strtolower($filePath);
307
308 // remove the first part of the path because some people don't want to see
309 // it in the log file.
310 $homepath = (string) dirname(ABSPATH);
311 $beginningOfPath = (string) substr($path, 0, strlen($homepath));
312 if (strtolower($beginningOfPath) === strtolower($homepath)) {
313 $path = (string) substr($path, strlen($homepath));
314 }
315
316 $supplement = array();
317
318 if (!$appendExtraData) {
319 $supplement['prefix'] = '';
320 $supplement['suffix'] = '';
321
322 } else if (self::endsWithCaseInsensitive($path, '.sql')) {
323 $supplement['prefix'] = "\n/* ------------------ " . $filePath . " BEGIN ----- */ \n";
324 $supplement['suffix'] = "\n/* ------------------ " . $filePath . " END ----- */ \n";
325
326 } else if (self::endsWithCaseInsensitive($path, '.html')) {
327 $supplement['prefix'] = "\n<!-- ------------------ " . $filePath . " BEGIN ----- --> \n";
328 $supplement['suffix'] = "\n<!-- ------------------ " . $filePath . " END ----- --> \n";
329
330 } else {
331 $supplement['prefix'] = "\n/* ------------------ " . $filePath . " BEGIN unknown file type in "
332 . __CLASS__ . '::' . __FUNCTION__ . "() ----- */ \n";
333 $supplement['suffix'] = "\n/* ------------------ " . $filePath . " END unknown file type in "
334 . __CLASS__ . '::' . __FUNCTION__ . "() ----- */ \n";
335 }
336
337 return $supplement;
338 }
339
340 private static function endsWithCaseInsensitive(string $haystack, string $needle): bool {
341 $length = strlen($needle);
342 if (strlen($haystack) < $length) {
343 return false;
344 }
345 return strtolower(substr($haystack, -$length)) === strtolower($needle);
346 }
347
348 /** Deletes the existing file at $filePath and puts the URL contents in it's place.
349 * @param string $url
350 * @param string $filePath
351 * @return void
352 */
353 static function readURLtoFile(string $url, string $filePath): void {
354 $abj404logging = abj_service('logging');
355
356 self::safeUnlink($filePath);
357
358 // if we can't read the file that way then try curl.
359 if (function_exists('curl_init')) {
360 try {
361 //This is the file where we save the information
362 $destinationFileWriteHandle = fopen($filePath, 'w+');
363 //Here is the file we are downloading, replace spaces with %20
364 $ch = curl_init(str_replace(" ", "%20", $url));
365 curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 '
366 . '(KHTML, like Gecko) Chrome/27.0.1453.94 Safari/537.36 (404 Solution WordPress Plugin)');
367 curl_setopt($ch, CURLOPT_TIMEOUT, 10);
368 // write curl response to file
369 if (is_resource($destinationFileWriteHandle)) {
370 curl_setopt($ch, CURLOPT_FILE, $destinationFileWriteHandle);
371 }
372 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
373 // get curl response
374 curl_exec($ch);
375 if (is_resource($destinationFileWriteHandle)) {
376 fclose($destinationFileWriteHandle);
377 }
378
379 if (file_exists($filePath) && filesize($filePath) > 0) {
380 return;
381 }
382 } catch (Exception $e) {
383 $abj404logging->debugMessage("curl didn't work for downloading a URL. " . $e->getMessage());
384 }
385 }
386
387 // Fallback to file_put_contents if curl didn't work or isn't available
388 self::safeUnlink($filePath);
389 try {
390 $fileHandle = @fopen($url, 'r');
391 if ($fileHandle === false) {
392 $abj404logging->errorMessage("Failed to open URL for reading: " . $url);
393 return;
394 }
395 $result = file_put_contents($filePath, $fileHandle);
396 fclose($fileHandle);
397
398 if ($result === false) {
399 $abj404logging->errorMessage("Failed to write file: " . $filePath);
400 }
401 } catch (Exception $e) {
402 $abj404logging->errorMessage("Failed to download URL to file. URL: " . $url . ", Error: " . $e->getMessage());
403 }
404 }
405
406 private static function logWarning(string $message): void {
407 $logger = function_exists('abj_service_optional') ? abj_service_optional('logging') : null;
408 if (is_object($logger) && method_exists($logger, 'warn')) {
409 $logger->warn($message);
410 return;
411 }
412
413 abj404_logPhpFallback('service-resolution-fallback', $message);
414 }
415 }
416