root/fFilesystem.php

Revision 758, 21.5 kB (checked in by wbond, 4 days ago)

Fixed ticket #397 - fFile, fImage and fDirectory all handle re-used filenames better and produce more usable exceptions when trying to perform actions on objects that have been deleted

InternalBackwardsCompatibilityBreak - fFilesystem::hookExceptionMap() was changed to fFilesystem::hookDeletedMap() and fFilesystem::updateExceptionMap() was changed to fFilesystem::updateDeletedMap()

LineHide Line Numbers
1 <?php
2 /**
3  * Handles filesystem-level tasks including filesystem transactions and the reference map to keep all fFile and fDirectory objects in sync
4  *
5  * @copyright  Copyright (c) 2008-2010 Will Bond, others
6  * @author     Will Bond [wb] <will@flourishlib.com>
7  * @author     Alex Leeds [al] <alex@kingleeds.com>
8  * @author     Will Bond, iMarc LLC [wb-imarc] <will@imarc.net>
9  * @license    http://flourishlib.com/license
10  *
11  * @package    Flourish
12  * @link       http://flourishlib.com/fFilesystem
13  *
14  * @version    1.0.0b13
15  * @changes    1.0.0b13  Changed the way files/directories deleted in a filesystem transaction are handled, including improvements to the exception that is thrown [wb+wb-imarc, 2010-03-05]
16  * @changes    1.0.0b12  Updated ::convertToBytes() to properly handle integers without a suffix and sizes with fractions [al+wb, 2009-11-14]
17  * @changes    1.0.0b11  Corrected the API documentation for ::getPathInfo() [wb, 2009-09-09]
18  * @changes    1.0.0b10  Updated ::updateExceptionMap() to not contain the Exception class parameter hint, allowing NULL to be passed [wb, 2009-08-20]
19  * @changes    1.0.0b9   Added some performance tweaks to ::createObject() [wb, 2009-08-06]
20  * @changes    1.0.0b8   Changed ::formatFilesize() to not use decimal places for bytes, add a space before and drop the `B` in suffixes [wb, 2009-07-12]
21  * @changes    1.0.0b7   Fixed ::formatFilesize() to work when `$bytes` equals zero [wb, 2009-07-08]
22  * @changes    1.0.0b6   Changed replacement values in preg_replace() calls to be properly escaped [wb, 2009-06-11]
23  * @changes    1.0.0b5   Changed ::formatFilesize() to use proper uppercase letters instead of lowercase [wb, 2009-06-02]
24  * @changes    1.0.0b4   Added the ::createObject() method [wb, 2009-01-21]
25  * @changes    1.0.0b3   Removed some unnecessary error suppresion operators [wb, 2008-12-11]
26  * @changes    1.0.0b2   Fixed a bug where the filepath and exception maps weren't being updated after a rollback [wb, 2008-12-11]
27  * @changes    1.0.0b    The initial implementation [wb, 2008-03-24]
28  */
29 class fFilesystem
30 {
31     // The following constants allow for nice looking callbacks to static methods
32     const addWebPathTranslation         = 'fFilesystem::addWebPathTranslation';
33     const begin                         = 'fFilesystem::begin';
34     const commit                        = 'fFilesystem::commit';
35     const convertToBytes                = 'fFilesystem::convertToBytes';
36     const createObject                  = 'fFilesystem::createObject';
37     const formatFilesize                = 'fFilesystem::formatFilesize';
38     const getPathInfo                   = 'fFilesystem::getPathInfo';
39     const hookDeletedMap                = 'fFilesystem::hookDeletedMap';
40     const hookFilenameMap               = 'fFilesystem::hookFilenameMap';
41     const isInsideTransaction           = 'fFilesystem::isInsideTransaction';
42     const makeUniqueName                = 'fFilesystem::makeUniqueName';
43     const recordCreate                  = 'fFilesystem::recordCreate';
44     const recordDelete                  = 'fFilesystem::recordDelete';
45     const recordDuplicate               = 'fFilesystem::recordDuplicate';
46     const recordRename                  = 'fFilesystem::recordRename';
47     const recordWrite                   = 'fFilesystem::recordWrite';
48     const reset                         = 'fFilesystem::reset';
49     const rollback                      = 'fFilesystem::rollback';
50     const translateToWebPath            = 'fFilesystem::translateToWebPath';
51     const updateDeletedMap              = 'fFilesystem::updateDeletedMap';
52     const updateFilenameMap             = 'fFilesystem::updateFilenameMap';
53     const updateFilenameMapForDirectory = 'fFilesystem::updateFilenameMapForDirectory';
54    
55    
56     /**
57     * Stores the operations to perform when a commit occurs
58     *
59     * @var array
60     */
61     static private $commit_operations = NULL;
62    
63     /**
64     * Maps deletion backtraces to all instances of a file or directory, providing consistency
65     *
66     * @var array
67     */
68     static private $deleted_map = array();
69    
70     /**
71     * Stores file and directory names by reference, allowing all object instances to be updated at once
72     *
73     * @var array
74     */
75     static private $filename_map = array();
76    
77     /**
78     * Stores the operations to perform if a rollback occurs
79     *
80     * @var array
81     */
82     static private $rollback_operations = NULL;
83    
84     /**
85     * Stores a list of search => replace strings for web path translations
86     *
87     * @var array
88     */
89     static private $web_path_translations = array();
90    
91    
92     /**
93     * Adds a directory to the web path translation list
94     *
95     * The web path conversion list is a list of directory paths that will be
96     * converted (from the beginning of filesystem paths) when preparing a path
97     * for output into HTML.
98     *
99     * By default the `$_SERVER['DOCUMENT_ROOT']` will be converted to a blank
100     * string, in essence stripping it from filesystem paths.
101     *
102     * @param  string $search_path   The path to look for
103     * @param  string $replace_path  The path to replace with
104     * @return void
105     */
106     static public function addWebPathTranslation($search_path, $replace_path)
107     {
108         // Ensure we have the correct kind of slash for the OS being used
109         $search_path  = str_replace(array('/', '\\'), DIRECTORY_SEPARATOR, $search_path);
110         $replace_path = str_replace(array('/', '\\'), DIRECTORY_SEPARATOR, $replace_path);
111         self::$web_path_translations[$search_path] = $replace_path;
112     }
113    
114    
115     /**
116     * Starts a filesystem pseudo-transaction, should only be called when no transaction is in progress.
117     *
118     * Flourish filesystem transactions are NOT full ACID-compliant
119     * transactions, but rather more of an filesystem undo buffer which can
120     * return the filesystem to the state when ::begin() was called. If your PHP
121     * script dies in the middle of an operation this functionality will do
122     * nothing for you and all operations will be retained, except for deletes
123     * which only occur once the transaction is committed.
124     *
125     * @return void
126     */
127     static public function begin()
128     {
129         if (self::$commit_operations !== NULL) {
130             throw new fProgrammerException(
131                 'There is already a filesystem transaction in progress'
132             );
133         }
134         self::$commit_operations   = array();
135         self::$rollback_operations = array();
136     }
137    
138    
139     /**
140     * Commits a filesystem transaction, should only be called when a transaction is in progress
141     *
142     * @return void
143     */
144     static public function commit()
145     {
146         if (!self::isInsideTransaction()) {
147             throw new fProgrammerException(
148                 'There is no filesystem transaction in progress to commit'
149             );
150         }
151        
152         $commit_operations = self::$commit_operations;
153        
154         self::$commit_operations   = NULL;
155         self::$rollback_operations = NULL;
156        
157         $commit_operations = array_reverse($commit_operations);
158        
159         foreach ($commit_operations as $operation) {
160             // Commit operations only include deletes, however it could be a filename or object
161             if (isset($operation['filename'])) {
162                 unlink($operation['filename']);
163             } else {
164                 $operation['object']->delete();
165             }
166         }
167     }
168    
169    
170     /**
171     * Takes a file size including a unit of measure (i.e. kb, GB, M) and converts it to bytes
172     *
173     * Sizes are interpreted using base 2, not base 10. Sizes above 2GB may not
174     * be accurately represented on 32 bit operating systems.
175     *
176     * @param  string $size  The size to convert to bytes
177     * @return integer  The number of bytes represented by the size
178     */
179     static public function convertToBytes($size)
180     {
181         if (!preg_match('#^(\d+(?:\.\d+)?)\s*(k|m|g|t)?(ilo|ega|era|iga)?( )?b?(yte(s)?)?$#D', strtolower(trim($size)), $matches)) {
182             throw new fProgrammerException(
183                 'The size specified, %s, does not appears to be a valid size',
184                 $size
185             );
186         }
187        
188         if (empty($matches[2])) {
189             $matches[2] = 'b';
190         }
191        
192         $size_map = array('b' => 1,
193                           'k' => 1024,
194                           'm' => 1048576,
195                           'g' => 1073741824,
196                           't' => 1099511627776);
197         return round($matches[1] * $size_map[$matches[2]]);
198     }
199    
200    
201     /**
202     * Takes a filesystem path and creates either an fDirectory, fFile or fImage object from it
203     *
204     * @throws fValidationException  When no path was specified or the path specified does not exist
205     *
206     * @param  string $path  The path to the filesystem object
207     * @return fDirectory|fFile|fImage
208     */
209     static public function createObject($path)
210     {
211         if (empty($path)) {
212             throw new fValidationException(
213                 'No path was specified'
214             );
215         }
216        
217         if (!is_readable($path)) {
218             throw new fValidationException(
219                 'The path specified, %s, does not exist or is not readable',
220                 $path
221             );
222         }
223        
224         if (is_dir($path)) {
225             return new fDirectory($path, TRUE);
226         }
227        
228         if (fImage::isImageCompatible($path)) {
229             return new fImage($path, TRUE);
230         }
231        
232         return new fFile($path, TRUE);
233     }
234    
235    
236     /**
237     * Takes the size of a file in bytes and returns a friendly size in B/K/M/G/T
238     *
239     * @param  integer $bytes           The size of the file in bytes
240     * @param  integer $decimal_places  The number of decimal places to display
241     * @return string
242     */
243     static public function formatFilesize($bytes, $decimal_places=1)
244     {
245         if ($bytes < 0) {
246             $bytes = 0;
247         }
248         $suffixes  = array('B', 'K', 'M', 'G', 'T');
249         $sizes     = array(1, 1024, 1048576, 1073741824, 1099511627776);
250         $suffix    = (!$bytes) ? 0 : floor(log($bytes)/6.9314718);
251         return number_format($bytes/$sizes[$suffix], ($suffix == 0) ? 0 : $decimal_places) . ' ' . $suffixes[$suffix];
252     }
253    
254    
255     /**
256     * Returns info about a path including dirname, basename, extension and filename
257     *
258     * @param  string $path     The file/directory path to retrieve information about
259     * @param  string $element  The piece of information to return: `'dirname'`, `'basename'`, `'extension'`, or `'filename'`
260     * @return array  The file's dirname, basename, extension and filename
261     */
262     static public function getPathInfo($path, $element=NULL)
263     {
264         $valid_elements = array('dirname', 'basename', 'extension', 'filename');
265         if ($element !== NULL && !in_array($element, $valid_elements)) {
266             throw new fProgrammerException(
267                 'The element specified, %1$s, is invalid. Must be one of: %2$s.',
268                 $element,
269                 join(', ', $valid_elements)
270             );
271         }
272        
273         $path_info = pathinfo($path);
274        
275         if (!isset($path_info['extension'])) {
276             $path_info['extension'] = NULL;
277         }
278        
279         if (!isset($path_info['filename'])) {
280             $path_info['filename'] = preg_replace('#\.' . preg_quote($path_info['extension'], '#') . '$#D', '', $path_info['basename']);
281         }
282         $path_info['dirname'] .= DIRECTORY_SEPARATOR;
283        
284         if ($element) {
285             return $path_info[$element];
286         }
287        
288         return $path_info;
289     }
290    
291    
292     /**
293     * Hooks a file/directory into the deleted backtrace map entry for that filename
294     *
295     * Since the value is returned by reference, all objects that represent
296     * this file/directory always see the same backtrace.
297     *
298     * @internal
299      *
300     * @param  string $file  The name of the file or directory
301     * @return mixed  Will return `NULL` if no match, or the backtrace array if a match occurs
302     */
303     static public function &hookDeletedMap($file)
304     {
305         if (!isset(self::$deleted_map[$file])) {
306             self::$deleted_map[$file] = NULL;
307         }
308         return self::$deleted_map[$file];
309     }
310    
311    
312     /**
313     * Hooks a file/directory name to the filename map
314     *
315     * Since the value is returned by reference, all objects that represent
316     * this file/directory will always be update on a rename.
317     *
318     * @internal
319      *
320     * @param  string $file  The name of the file or directory
321     * @return mixed  Will return `NULL` if no match, or the exception object if a match occurs
322     */
323     static public function &hookFilenameMap($file)
324     {
325         if (!isset(self::$filename_map[$file])) {
326             self::$filename_map[$file] = $file;
327         }
328         return self::$filename_map[$file];
329     }
330    
331    
332     /**
333     * Indicates if a transaction is in progress
334     *
335     * @return void
336     */
337     static public function isInsideTransaction()
338     {
339         return is_array(self::$commit_operations);
340     }
341    
342    
343     /**
344     * Changes a filename to be safe for URLs by making it all lower case and changing everything but letters, numers, - and . to _
345     *
346     * @param  string $filename  The filename to clean up
347     * @return string  The cleaned up filename
348     */
349     static public function makeURLSafe($filename)
350     {
351         $filename = strtolower(trim($filename));
352         $filename = str_replace("'", '', $filename);
353         return preg_replace('#[^a-z0-9\-\.]+#', '_', $filename);   
354     }
355    
356    
357     /**
358     * Returns a unique name for a file
359     *
360     * @param  string $file           The filename to check
361     * @param  string $new_extension  The new extension for the filename, should not include `.`
362     * @return string  The unique file name
363     */
364     static public function makeUniqueName($file, $new_extension=NULL)
365     {
366         $info = self::getPathInfo($file);
367        
368         // Change the file extension
369         if ($new_extension !== NULL) {
370             $new_extension = ($new_extension) ? '.' . $new_extension : $new_extension;
371             $file = $info['dirname'] . $info['filename'] . $new_extension;
372             $info = self::getPathInfo($file);
373         }
374        
375         // If there is an extension, be sure to add . before it
376         $extension = (!empty($info['extension'])) ? '.' . $info['extension'] : '';
377        
378         // Remove _copy# from the filename to start
379         $file = preg_replace('#_copy(\d+)' . preg_quote($extension, '#') . '$#D', $extension, $file);
380        
381         // Look for a unique name by adding _copy# to the end of the file
382         while (file_exists($file)) {
383             $info = self::getPathInfo($file);
384             if (preg_match('#_copy(\d+)' . preg_quote($extension, '#') . '$#D', $file, $match)) {
385                 $file = preg_replace('#_copy(\d+)' . preg_quote($extension, '#') . '$#D', '_copy' . ($match[1]+1) . $extension, $file);
386             } else {
387                 $file = $info['dirname'] . $info['filename'] . '_copy1' . $extension;
388             }
389         }
390        
391         return $file;
392     }
393    
394    
395     /**
396     * Updates the deleted backtrace for a file or directory
397     *
398     * @internal
399      *
400     * @param  string $file          A file or directory name, directories should end in `/` or `\`
401     * @param  array  $backtrace  The backtrace for this file/directory
402     * @return void
403     */
404     static public function updateDeletedMap($file, $backtrace)
405     {
406         self::$deleted_map[$file] = $backtrace;
407     }
408    
409    
410     /**
411     * Updates the filename map, causing all objects representing a file/directory to be updated
412     *
413     * @internal
414      *
415     * @param  string $existing_filename  The existing filename
416     * @param  string $new_filename       The new filename
417     * @return void
418     */
419     static public function updateFilenameMap($existing_filename, $new_filename)
420     {
421         if ($existing_filename == $new_filename) {
422             return;
423         }
424        
425         self::$filename_map[$new_filename] =& self::$filename_map[$existing_filename];
426         self::$deleted_map[$new_filename]  =& self::$deleted_map[$existing_filename];
427        
428         unset(self::$filename_map[$existing_filename]);
429         unset(self::$deleted_map[$existing_filename]);
430        
431         self::$filename_map[$new_filename] = $new_filename;
432     }
433    
434    
435     /**
436     * Updates the filename map recursively, causing all objects representing a directory to be updated
437     *
438     * Also updates all files and directories in the specified directory to the new paths.
439     *
440     * @internal
441      *
442     * @param  string $existing_dirname  The existing directory name
443     * @param  string $new_dirname       The new dirname
444     * @return void
445     */
446     static public function updateFilenameMapForDirectory($existing_dirname, $new_dirname)
447     {
448         if ($existing_dirname == $new_dirname) {
449             return;
450         }
451        
452         // Handle the directory name
453         self::$filename_map[$new_dirname] =& self::$filename_map[$existing_dirname];
454         self::$deleted_map[$new_dirname]  =& self::$deleted_map[$existing_dirname];
455        
456         unset(self::$filename_map[$existing_dirname]);
457         unset(self::$deleted_map[$existing_dirname]);
458        
459         self::$filename_map[$new_dirname] = $new_dirname;
460        
461         // Handle all of the directories and files inside this directory
462         foreach (self::$filename_map as $filename => $ignore) {
463             if (preg_match('#^' . preg_quote($existing_dirname, '#') . '#', $filename)) {
464                 $new_filename = preg_replace(
465                     '#^' . preg_quote($existing_dirname, '#') . '#',
466                     strtr($new_dirname, array('\\' => '\\\\', '$' => '\\$')),
467                     $filename
468                 );
469                
470                 self::$filename_map[$new_filename] =& self::$filename_map[$filename];
471                 self::$deleted_map[$new_filename]  =& self::$deleted_map[$filename];
472                
473                 unset(self::$filename_map[$filename]);
474                 unset(self::$deleted_map[$filename]);
475                
476                 self::$filename_map[$new_filename] = $new_filename;
477                    
478             }
479         }
480     }
481    
482    
483     /**
484     * Keeps a record of created files so they can be deleted up in case of a rollback
485     *
486     * @internal
487      *
488     * @param  object $object  The new file or directory to get rid of on rollback
489     * @return void
490     */
491     static public function recordCreate($object)
492     {
493         self::$rollback_operations[] = array(
494             'action' => 'delete',
495             'object' => $object
496         );
497     }
498    
499    
500     /**
501     * Keeps track of file and directory names to delete when a transaction is committed
502     *
503     * @internal
504      *
505     * @param  fFile|fDirectory $object  The filesystem object to delete
506     * @return void
507     */
508     static public function recordDelete($object)
509     {
510         self::$commit_operations[] = array(
511             'action' => 'delete',
512             'object' => $object
513         );
514     }
515    
516    
517     /**
518     * Keeps a record of duplicated files so they can be cleaned up in case of a rollback
519     *
520     * @internal
521      *
522     * @param  fFile $file  The duplicate file to get rid of on rollback
523     * @return void
524     */
525     static public function recordDuplicate($file)
526     {
527         self::$rollback_operations[] = array(
528             'action'   => 'delete',
529             'filename' => $file->getPath()
530         );
531     }
532    
533    
534     /**
535     * Keeps a temp file in place of the old filename so the file can be restored during a rollback
536     *
537     * @internal
538      *
539     * @param  string $old_name  The old file or directory name
540     * @param  string $new_name  The new file or directory name
541     * @return void
542     */
543     static public function recordRename($old_name, $new_name)
544     {
545         self::$rollback_operations[] = array(
546             'action'   => 'rename',
547             'old_name' => $old_name,
548             'new_name' => $new_name
549         );
550        
551         // Create the file with no content to prevent overwriting by another process
552         file_put_contents($old_name, '');
553        
554         self::$commit_operations[] = array(
555             'action'   => 'delete',
556             'filename' => $old_name
557         );
558     }
559    
560    
561     /**
562     * Keeps backup copies of files so they can be restored if there is a rollback
563     *
564     * @internal
565      *
566     * @param  fFile $file  The file that is being written to
567     * @return void
568     */
569     static public function recordWrite($file)
570     {
571         self::$rollback_operations[] = array(
572             'action'   => 'write',
573             'filename' => $file->getPath(),
574             'old_data' => file_get_contents($file->getPath())
575         );
576     }
577    
578    
579     /**
580     * Resets the configuration of the class
581     *
582     * @internal
583      *
584     * @return void
585     */
586     static public function reset()
587     {
588         self::rollback();
589         self::$deleted_map           = array();
590         self::$filename_map          = array();
591         self::$web_path_translations = array();   
592     }
593    
594    
595     /**
596     * Rolls back a filesystem transaction, it is safe to rollback when no transaction is in progress
597     *
598     * @return void
599     */
600     static public function rollback()
601     {
602         self::$rollback_operations = array_reverse(self::$rollback_operations);
603        
604         foreach (self::$rollback_operations as $operation) {
605             switch($operation['action']) {
606                
607                 case 'delete':
608                     self::updateDeletedMap(
609                         $operation['filename'],
610                         debug_backtrace()
611                     );
612                     unlink($operation['filename']);
613                     fFilesystem::updateFilenameMap($operation['filename'], '*DELETED at ' . time() . ' with token ' . uniqid('', TRUE) . '* ' . $operation['filename']);
614                     break;
615                    
616                 case 'write':
617                     file_put_contents($operation['filename'], $operation['old_data']);
618                     break;
619                    
620                 case 'rename':
621                     fFilesystem::updateFilenameMap($operation['new_name'], $operation['old_name']);
622                     rename($operation['new_name'], $operation['old_name']);
623                     break;
624                    
625             }
626         }
627        
628         // All files to be deleted should have their backtraces erased
629         foreach (self::$commit_operations as $operation) {
630             if (isset($operation['object'])) {
631                 self::updateDeletedMap($operation['object']->getPath(), NULL);
632                 fFilesystem::updateFilenameMap($operation['object']->getPath(), preg_replace('#*DELETED at \d+ with token [\w.]+* #', '', $operation['filename']));
633             }
634         }
635        
636         self::$commit_operations   = NULL;
637         self::$rollback_operations = NULL;
638     }
639    
640    
641     /**
642     * Takes a filesystem path and translates it to a web path using the rules added
643     *
644     * @param  string $path  The path to translate
645     * @return string  The filesystem path translated to a web path
646     */
647     static public function translateToWebPath($path)
648     {
649         $translations = array(realpath($_SERVER['DOCUMENT_ROOT']) => '') + self::$web_path_translations;
650        
651         foreach ($translations as $search => $replace) {
652             $path = preg_replace(
653                 '#^' . preg_quote($search, '#') . '#',
654                 strtr($replace, array('\\' => '\\\\', '$' => '\\$')),
655                 $path
656             );
657         }
658        
659         return $path;
660     }
661    
662    
663     /**
664     * Forces use as a static class
665     *
666     * @return fFilesystem
667     */
668     private function __construct() { }
669 }
670  
671  
672  
673 /**
674  * Copyright (c) 2008-2010 Will Bond <will@flourishlib.com>, others
675  *
676  * Permission is hereby granted, free of charge, to any person obtaining a copy
677  * of this software and associated documentation files (the "Software"), to deal
678  * in the Software without restriction, including without limitation the rights
679  * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
680  * copies of the Software, and to permit persons to whom the Software is
681  * furnished to do so, subject to the following conditions:
682  *
683  * The above copyright notice and this permission notice shall be included in
684  * all copies or substantial portions of the Software.
685  *
686  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
687  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
688  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
689  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
690  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
691  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
692  * THE SOFTWARE.
693  */