1 <?php
2 /**
3 * @package Joomla.Platform
4 * @subpackage Cache
5 *
6 * @copyright Copyright (C) 2005 - 2017 Open Source Matters, Inc. All rights reserved.
7 * @license GNU General Public License version 2 or later; see LICENSE
8 */
9
10 defined('JPATH_PLATFORM') or die;
11
12 /**
13 * File cache storage handler
14 *
15 * @since 11.1
16 * @note For performance reasons this class does not use the Filesystem package's API
17 */
18 class JCacheStorageFile extends JCacheStorage
19 {
20 /**
21 * Root path
22 *
23 * @var string
24 * @since 11.1
25 */
26 protected $_root;
27
28 /**
29 * Locked resources
30 *
31 * @var array
32 * @since 3.7.0
33 *
34 */
35 protected $_locked_files = array();
36
37 /**
38 * Constructor
39 *
40 * @param array $options Optional parameters
41 *
42 * @since 11.1
43 */
44 public function __construct($options = array())
45 {
46 parent::__construct($options);
47 $this->_root = $options['cachebase'];
48
49 // Workaround for php 5.3
50 $locked_files = &$this->_locked_files;
51
52 // Remove empty locked files at script shutdown.
53 $clearAtShutdown = function () use (&$locked_files)
54 {
55 foreach ($locked_files as $path => $handle)
56 {
57 if (is_resource($handle))
58 {
59 @flock($handle, LOCK_UN);
60 @fclose($handle);
61 }
62
63 // Delete only the existing file if it is empty.
64 if (@filesize($path) === 0)
65 {
66 @unlink($path);
67 }
68
69 unset($locked_files[$path]);
70 }
71 };
72
73 register_shutdown_function($clearAtShutdown);
74 }
75
76 /**
77 * Check if the cache contains data stored by ID and group
78 *
79 * @param string $id The cache data ID
80 * @param string $group The cache data group
81 *
82 * @return boolean
83 *
84 * @since 3.7.0
85 */
86 public function contains($id, $group)
87 {
88 return $this->_checkExpire($id, $group);
89 }
90
91 /**
92 * Get cached data by ID and group
93 *
94 * @param string $id The cache data ID
95 * @param string $group The cache data group
96 * @param boolean $checkTime True to verify cache time expiration threshold
97 *
98 * @return mixed Boolean false on failure or a cached data object
99 *
100 * @since 11.1
101 */
102 public function get($id, $group, $checkTime = true)
103 {
104 $path = $this->_getFilePath($id, $group);
105 $close = false;
106
107 if ($checkTime == false || ($checkTime == true && $this->_checkExpire($id, $group) === true))
108 {
109 if (file_exists($path))
110 {
111 if (isset($this->_locked_files[$path]))
112 {
113 $_fileopen = $this->_locked_files[$path];
114 }
115 else
116 {
117 $_fileopen = @fopen($path, 'rb');
118
119 // There is no lock, we have to close file after store data
120 $close = true;
121 }
122
123 if ($_fileopen)
124 {
125 // On Windows system we can not use file_get_contents on the file locked by yourself
126 $data = stream_get_contents($_fileopen);
127
128 if ($close)
129 {
130 @fclose($_fileopen);
131 }
132
133 if ($data !== false)
134 {
135 // Remove the initial die() statement
136 return str_replace('<?php die("Access Denied"); ?>#x#', '', $data);
137 }
138 }
139 }
140 }
141
142 return false;
143 }
144
145 /**
146 * Get all cached data
147 *
148 * @return mixed Boolean false on failure or a cached data object
149 *
150 * @since 11.1
151 */
152 public function getAll()
153 {
154 $path = $this->_root;
155 $folders = $this->_folders($path);
156 $data = array();
157
158 foreach ($folders as $folder)
159 {
160 $files = $this->_filesInFolder($path . '/' . $folder);
161 $item = new JCacheStorageHelper($folder);
162
163 foreach ($files as $file)
164 {
165 $item->updateSize(filesize($path . '/' . $folder . '/' . $file));
166 }
167
168 $data[$folder] = $item;
169 }
170
171 return $data;
172 }
173
174 /**
175 * Store the data to cache by ID and group
176 *
177 * @param string $id The cache data ID
178 * @param string $group The cache data group
179 * @param string $data The data to store in cache
180 *
181 * @return boolean
182 *
183 * @since 11.1
184 */
185 public function store($id, $group, $data)
186 {
187 $path = $this->_getFilePath($id, $group);
188 $close = false;
189
190 // Prepend a die string
191 $data = '<?php die("Access Denied"); ?>#x#' . $data;
192
193 if (isset($this->_locked_files[$path]))
194 {
195 $_fileopen = $this->_locked_files[$path];
196
197 // Because lock method uses flag c+b we have to truncate it manually
198 @ftruncate($_fileopen, 0);
199 }
200 else
201 {
202 $_fileopen = @fopen($path, 'wb');
203
204 // There is no lock, we have to close file after store data
205 $close = true;
206 }
207
208 if ($_fileopen)
209 {
210 $length = strlen($data);
211 $result = @fwrite($_fileopen, $data, $length);
212
213 if ($close)
214 {
215 @fclose($_fileopen);
216 }
217
218 return $result === $length;
219 }
220
221 return false;
222 }
223
224 /**
225 * Remove a cached data entry by ID and group
226 *
227 * @param string $id The cache data ID
228 * @param string $group The cache data group
229 *
230 * @return boolean
231 *
232 * @since 11.1
233 */
234 public function remove($id, $group)
235 {
236 $path = $this->_getFilePath($id, $group);
237
238 if (!@unlink($path))
239 {
240 return false;
241 }
242
243 return true;
244 }
245
246 /**
247 * Clean cache for a group given a mode.
248 *
249 * group mode : cleans all cache in the group
250 * notgroup mode : cleans all cache not in the group
251 *
252 * @param string $group The cache data group
253 * @param string $mode The mode for cleaning cache [group|notgroup]
254 *
255 * @return boolean
256 *
257 * @since 11.1
258 */
259 public function clean($group, $mode = null)
260 {
261 $return = true;
262 $folder = $group;
263
264 if (trim($folder) == '')
265 {
266 $mode = 'notgroup';
267 }
268
269 switch ($mode)
270 {
271 case 'notgroup' :
272 $folders = $this->_folders($this->_root);
273
274 for ($i = 0, $n = count($folders); $i < $n; $i++)
275 {
276 if ($folders[$i] != $folder)
277 {
278 $return |= $this->_deleteFolder($this->_root . '/' . $folders[$i]);
279 }
280 }
281
282 break;
283
284 case 'group' :
285 default :
286 if (is_dir($this->_root . '/' . $folder))
287 {
288 $return = $this->_deleteFolder($this->_root . '/' . $folder);
289 }
290
291 break;
292 }
293
294 return (bool) $return;
295 }
296
297 /**
298 * Garbage collect expired cache data
299 *
300 * @return boolean
301 *
302 * @since 11.1
303 */
304 public function gc()
305 {
306 $result = true;
307
308 // Files older than lifeTime get deleted from cache
309 $files = $this->_filesInFolder($this->_root, '', true, true, array('.svn', 'CVS', '.DS_Store', '__MACOSX', 'index.html'));
310
311 foreach ($files as $file)
312 {
313 $time = @filemtime($file);
314
315 if (($time + $this->_lifetime) < $this->_now || empty($time))
316 {
317 $result |= @unlink($file);
318 }
319 }
320
321 return (bool) $result;
322 }
323
324 /**
325 * Test to see if the storage handler is available.
326 *
327 * @return boolean
328 *
329 * @since 12.1
330 */
331 public static function isSupported()
332 {
333 return is_writable(JFactory::getConfig()->get('cache_path', JPATH_CACHE));
334 }
335
336 /**
337 * Lock cached item
338 *
339 * @param string $id The cache data ID
340 * @param string $group The cache data group
341 * @param integer $locktime Cached item max lock time
342 *
343 * @return mixed Boolean false if locking failed or an object containing properties lock and locklooped
344 *
345 * @since 11.1
346 */
347 public function lock($id, $group, $locktime)
348 {
349 $returning = new stdClass;
350 $returning->locklooped = false;
351
352 $looptime = $locktime * 10;
353 $path = $this->_getFilePath($id, $group);
354 $_fileopen = @fopen($path, 'c+b');
355
356 if (!$_fileopen)
357 {
358 $returning->locked = false;
359
360 return $returning;
361 }
362
363 $data_lock = (bool) @flock($_fileopen, LOCK_EX|LOCK_NB);
364
365 if ($data_lock === false)
366 {
367 $lock_counter = 0;
368
369 // Loop until you find that the lock has been released.
370 // That implies that data get from other thread has finished
371 while ($data_lock === false)
372 {
373 if ($lock_counter > $looptime)
374 {
375 break;
376 }
377
378 usleep(100);
379 $data_lock = (bool) @flock($_fileopen, LOCK_EX|LOCK_NB);
380 $lock_counter++;
381 }
382
383 $returning->locklooped = true;
384 }
385
386 if ($data_lock === true)
387 {
388 // Remember resource, flock release lock if you unset/close resource
389 $this->_locked_files[$path] = $_fileopen;
390 }
391
392 $returning->locked = $data_lock;
393
394 return $returning;
395 }
396
397 /**
398 * Unlock cached item
399 *
400 * @param string $id The cache data ID
401 * @param string $group The cache data group
402 *
403 * @return boolean
404 *
405 * @since 11.1
406 */
407 public function unlock($id, $group = null)
408 {
409 $path = $this->_getFilePath($id, $group);
410
411 if (isset($this->_locked_files[$path]))
412 {
413 $ret = (bool) @flock($this->_locked_files[$path], LOCK_UN);
414 @fclose($this->_locked_files[$path]);
415 unset($this->_locked_files[$path]);
416
417 return $ret;
418 }
419
420 return true;
421 }
422
423 /**
424 * Check if a cache object has expired
425 *
426 * Using @ error suppressor here because between if we did a file_exists() and then filemsize() there will
427 * be a little time space when another process can delete the file and then you get PHP Warning
428 *
429 * @param string $id Cache ID to check
430 * @param string $group The cache data group
431 *
432 * @return boolean True if the cache ID is valid
433 *
434 * @since 11.1
435 */
436 protected function _checkExpire($id, $group)
437 {
438 $path = $this->_getFilePath($id, $group);
439
440 // Check prune period
441 if (file_exists($path))
442 {
443 $time = @filemtime($path);
444
445 if (($time + $this->_lifetime) < $this->_now || empty($time))
446 {
447 @unlink($path);
448
449 return false;
450 }
451
452 // If, right now, the file does not exist then return false
453 if (@filesize($path) == 0)
454 {
455 return false;
456 }
457
458 return true;
459 }
460
461 return false;
462 }
463
464 /**
465 * Get a cache file path from an ID/group pair
466 *
467 * @param string $id The cache data ID
468 * @param string $group The cache data group
469 *
470 * @return boolean|string The path to the data object or boolean false if the cache directory does not exist
471 *
472 * @since 11.1
473 */
474 protected function _getFilePath($id, $group)
475 {
476 $name = $this->_getCacheId($id, $group);
477 $dir = $this->_root . '/' . $group;
478
479 // If the folder doesn't exist try to create it
480 if (!is_dir($dir))
481 {
482 // Make sure the index file is there
483 $indexFile = $dir . '/index.html';
484 @mkdir($dir) && file_put_contents($indexFile, '<!DOCTYPE html><title></title>');
485 }
486
487 // Make sure the folder exists
488 if (!is_dir($dir))
489 {
490 return false;
491 }
492
493 return $dir . '/' . $name . '.php';
494 }
495
496 /**
497 * Quickly delete a folder of files
498 *
499 * @param string $path The path to the folder to delete.
500 *
501 * @return boolean
502 *
503 * @since 11.1
504 */
505 protected function _deleteFolder($path)
506 {
507 // Sanity check
508 if (!$path || !is_dir($path) || empty($this->_root))
509 {
510 // Bad programmer! Bad, bad programmer!
511 JLog::add(__METHOD__ . ' ' . JText::_('JLIB_FILESYSTEM_ERROR_DELETE_BASE_DIRECTORY'), JLog::WARNING, 'jerror');
512
513 return false;
514 }
515
516 $path = $this->_cleanPath($path);
517
518 // Check to make sure path is inside cache folder, we do not want to delete Joomla root!
519 $pos = strpos($path, $this->_cleanPath($this->_root));
520
521 if ($pos === false || $pos > 0)
522 {
523 JLog::add(__METHOD__ . ' ' . JText::sprintf('JLIB_FILESYSTEM_ERROR_PATH_IS_NOT_A_FOLDER', $path), JLog::WARNING, 'jerror');
524
525 return false;
526 }
527
528 // Remove all the files in folder if they exist; disable all filtering
529 $files = $this->_filesInFolder($path, '.', false, true, array(), array());
530
531 if (!empty($files) && !is_array($files))
532 {
533 if (@unlink($files) !== true)
534 {
535 return false;
536 }
537 }
538 elseif (!empty($files) && is_array($files))
539 {
540 foreach ($files as $file)
541 {
542 $file = $this->_cleanPath($file);
543
544 // In case of restricted permissions we zap it one way or the other as long as the owner is either the webserver or the ftp
545 if (@unlink($file) !== true)
546 {
547 JLog::add(__METHOD__ . ' ' . JText::sprintf('JLIB_FILESYSTEM_DELETE_FAILED', basename($file)), JLog::WARNING, 'jerror');
548
549 return false;
550 }
551 }
552 }
553
554 // Remove sub-folders of folder; disable all filtering
555 $folders = $this->_folders($path, '.', false, true, array(), array());
556
557 foreach ($folders as $folder)
558 {
559 if (is_link($folder))
560 {
561 // Don't descend into linked directories, just delete the link.
562 if (@unlink($folder) !== true)
563 {
564 return false;
565 }
566 }
567 elseif ($this->_deleteFolder($folder) !== true)
568 {
569 return false;
570 }
571 }
572
573 // In case of restricted permissions we zap it one way or the other as long as the owner is either the webserver or the ftp
574 if (@rmdir($path))
575 {
576 return true;
577 }
578
579 JLog::add(__METHOD__ . ' ' . JText::sprintf('JLIB_FILESYSTEM_ERROR_FOLDER_DELETE', $path), JLog::WARNING, 'jerror');
580
581 return false;
582 }
583
584 /**
585 * Function to strip additional / or \ in a path name
586 *
587 * @param string $path The path to clean
588 * @param string $ds Directory separator (optional)
589 *
590 * @return string The cleaned path
591 *
592 * @since 11.1
593 */
594 protected function _cleanPath($path, $ds = DIRECTORY_SEPARATOR)
595 {
596 $path = trim($path);
597
598 if (empty($path))
599 {
600 return $this->_root;
601 }
602
603 // Remove double slashes and backslahses and convert all slashes and backslashes to DIRECTORY_SEPARATOR
604 $path = preg_replace('#[/\\\\]+#', $ds, $path);
605
606 return $path;
607 }
608
609 /**
610 * Utility function to quickly read the files in a folder.
611 *
612 * @param string $path The path of the folder to read.
613 * @param string $filter A filter for file names.
614 * @param mixed $recurse True to recursively search into sub-folders, or an integer to specify the maximum depth.
615 * @param boolean $fullpath True to return the full path to the file.
616 * @param array $exclude Array with names of files which should not be shown in the result.
617 * @param array $excludefilter Array of folder names to exclude
618 *
619 * @return array Files in the given folder.
620 *
621 * @since 11.1
622 */
623 protected function _filesInFolder($path, $filter = '.', $recurse = false, $fullpath = false,
624 $exclude = array('.svn', 'CVS', '.DS_Store', '__MACOSX'), $excludefilter = array('^\..*', '.*~'))
625 {
626 $arr = array();
627
628 // Check to make sure the path valid and clean
629 $path = $this->_cleanPath($path);
630
631 // Is the path a folder?
632 if (!is_dir($path))
633 {
634 JLog::add(__METHOD__ . ' ' . JText::sprintf('JLIB_FILESYSTEM_ERROR_PATH_IS_NOT_A_FOLDER', $path), JLog::WARNING, 'jerror');
635
636 return false;
637 }
638
639 // Read the source directory.
640 if (!($handle = @opendir($path)))
641 {
642 return $arr;
643 }
644
645 if (count($excludefilter))
646 {
647 $excludefilter = '/(' . implode('|', $excludefilter) . ')/';
648 }
649 else
650 {
651 $excludefilter = '';
652 }
653
654 while (($file = readdir($handle)) !== false)
655 {
656 if (($file != '.') && ($file != '..') && (!in_array($file, $exclude)) && (!$excludefilter || !preg_match($excludefilter, $file)))
657 {
658 $dir = $path . '/' . $file;
659 $isDir = is_dir($dir);
660
661 if ($isDir)
662 {
663 if ($recurse)
664 {
665 if (is_int($recurse))
666 {
667 $arr2 = $this->_filesInFolder($dir, $filter, $recurse - 1, $fullpath);
668 }
669 else
670 {
671 $arr2 = $this->_filesInFolder($dir, $filter, $recurse, $fullpath);
672 }
673
674 $arr = array_merge($arr, $arr2);
675 }
676 }
677 else
678 {
679 if (preg_match("/$filter/", $file))
680 {
681 if ($fullpath)
682 {
683 $arr[] = $path . '/' . $file;
684 }
685 else
686 {
687 $arr[] = $file;
688 }
689 }
690 }
691 }
692 }
693
694 closedir($handle);
695
696 return $arr;
697 }
698
699 /**
700 * Utility function to read the folders in a folder.
701 *
702 * @param string $path The path of the folder to read.
703 * @param string $filter A filter for folder names.
704 * @param mixed $recurse True to recursively search into sub-folders, or an integer to specify the maximum depth.
705 * @param boolean $fullpath True to return the full path to the folders.
706 * @param array $exclude Array with names of folders which should not be shown in the result.
707 * @param array $excludefilter Array with regular expressions matching folders which should not be shown in the result.
708 *
709 * @return array Folders in the given folder.
710 *
711 * @since 11.1
712 */
713 protected function _folders($path, $filter = '.', $recurse = false, $fullpath = false, $exclude = array('.svn', 'CVS', '.DS_Store', '__MACOSX'),
714 $excludefilter = array('^\..*'))
715 {
716 $arr = array();
717
718 // Check to make sure the path valid and clean
719 $path = $this->_cleanPath($path);
720
721 // Is the path a folder?
722 if (!is_dir($path))
723 {
724 JLog::add(__METHOD__ . ' ' . JText::sprintf('JLIB_FILESYSTEM_ERROR_PATH_IS_NOT_A_FOLDER', $path), JLog::WARNING, 'jerror');
725
726 return false;
727 }
728
729 // Read the source directory
730 if (!($handle = @opendir($path)))
731 {
732 return $arr;
733 }
734
735 if (count($excludefilter))
736 {
737 $excludefilter_string = '/(' . implode('|', $excludefilter) . ')/';
738 }
739 else
740 {
741 $excludefilter_string = '';
742 }
743
744 while (($file = readdir($handle)) !== false)
745 {
746 if (($file != '.') && ($file != '..')
747 && (!in_array($file, $exclude))
748 && (empty($excludefilter_string) || !preg_match($excludefilter_string, $file)))
749 {
750 $dir = $path . '/' . $file;
751 $isDir = is_dir($dir);
752
753 if ($isDir)
754 {
755 // Removes filtered directories
756 if (preg_match("/$filter/", $file))
757 {
758 if ($fullpath)
759 {
760 $arr[] = $dir;
761 }
762 else
763 {
764 $arr[] = $file;
765 }
766 }
767
768 if ($recurse)
769 {
770 if (is_int($recurse))
771 {
772 $arr2 = $this->_folders($dir, $filter, $recurse - 1, $fullpath, $exclude, $excludefilter);
773 }
774 else
775 {
776 $arr2 = $this->_folders($dir, $filter, $recurse, $fullpath, $exclude, $excludefilter);
777 }
778
779 $arr = array_merge($arr, $arr2);
780 }
781 }
782 }
783 }
784
785 closedir($handle);
786
787 return $arr;
788 }
789 }
790