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