1: <?php
2: /*****************************************************************************************
3: * X2Engine Open Source Edition is a customer relationship management program developed by
4: * X2Engine, Inc. Copyright (C) 2011-2016 X2Engine Inc.
5: *
6: * This program is free software; you can redistribute it and/or modify it under
7: * the terms of the GNU Affero General Public License version 3 as published by the
8: * Free Software Foundation with the addition of the following permission added
9: * to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED WORK
10: * IN WHICH THE COPYRIGHT IS OWNED BY X2ENGINE, X2ENGINE DISCLAIMS THE WARRANTY
11: * OF NON INFRINGEMENT OF THIRD PARTY RIGHTS.
12: *
13: * This program is distributed in the hope that it will be useful, but WITHOUT
14: * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
15: * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
16: * details.
17: *
18: * You should have received a copy of the GNU Affero General Public License along with
19: * this program; if not, see http://www.gnu.org/licenses or write to the Free
20: * Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
21: * 02110-1301 USA.
22: *
23: * You can contact X2Engine, Inc. P.O. Box 66752, Scotts Valley,
24: * California 95067, USA. or at email address contact@x2engine.com.
25: *
26: * The interactive user interfaces in modified source and object code versions
27: * of this program must display Appropriate Legal Notices, as required under
28: * Section 5 of the GNU Affero General Public License version 3.
29: *
30: * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
31: * these Appropriate Legal Notices must retain the display of the "Powered by
32: * X2Engine" logo. If the display of the logo is not reasonably feasible for
33: * technical reasons, the Appropriate Legal Notices must display the words
34: * "Powered by X2Engine".
35: *****************************************************************************************/
36:
37: /**
38: * Standalone file manipulation class.
39: *
40: * Miscellaneous file system utilities. It is not a child class of CComponent or
41: * the like in order to be portable/stand-alone (so it can be used outside the
42: * app, i.e. by the installer).
43: *
44: * @package application.components.util
45: * @author Demitri Morgan <demitri@x2engine.com>
46: */
47: class FileUtil {
48:
49: const ERR_FTPOPER = 100;
50:
51: public static $_finfo;
52:
53: public static $alwaysCurl = false;
54:
55: public static $fileOper = "php";
56: public static $ftpChroot = false;
57: private static $_ftpStream;
58:
59: /**
60: * Copies a file or directory recursively.
61: *
62: * If the local filesystem directory to where the file will be copied does
63: * not exist yet, it will be created automatically. Furthermore, if a remote
64: * URL is being accessed and allow_url_fopen isn't set, it will attempt to
65: * use CURL instead.
66: *
67: * @param string $source The source file.
68: * @param string $target The destination path.
69: * @param boolean $relTarget Transform the target to a relative path. This
70: * option must be false unless the target is an absolute path.
71: * @param boolean $contents If true, and the source is a directory, its
72: * contents will be copied to the destination; otherwise, the whole directory
73: * will be copied into the destination directory.
74: * @return boolean
75: */
76: public static function ccopy($source, $target, $relTarget = false, $contents = true){
77: $ds = DIRECTORY_SEPARATOR;
78: $remote = (bool) preg_match('%^https?://%', $source);
79: // Normalize target to the form where if it's a directory it doesn't have
80: // a trailing slash, and is platform-agnostic:
81: $target = rtrim(self::rpath($target), $ds);
82: // Make the target into a relative path:
83: if($relTarget && self::$fileOper !== 'ftp')
84: $target = self::relpath($target);
85: // Safeguard against overwriting files:
86: if(!$remote && is_dir($source) && !is_dir($target) && is_file($target))
87: throw new Exception("Cannot copy a directory ($source) into a file ($target).");
88:
89: // Create parent directories if they don't exist already.
90: //
91: // If a file is being copied: the path to examine for directory creation
92: // is one lower than the target (the bottom-level parent).
93: // If a directory is being copied: the same is true (to not create the
94: // top level node) even though it's a directory, because the target
95: // directory will be created anyway if necessary
96: // If a directory is being copied and $contents is false: it's assumed
97: // that the target is a destination directory and not part of the tree
98: // to be copied.
99: $pathNodes = explode($ds, self::ftpStripChroot($target));
100: if($contents)
101: array_pop($pathNodes);
102: for($i = 0; $i <= count($pathNodes); $i++){
103: $parent = implode($ds, array_slice($pathNodes, 0, $i));
104: // If we are using an FTP chroot, prepend the $parent path with the chroot dir
105: // so that is_dir() is accurate.
106: if (self::$fileOper === 'ftp' && self::$ftpChroot !== false && !self::isRelative($parent))
107: $verifyDir = self::$ftpChroot.$parent;
108: else
109: $verifyDir = $parent;
110: if($parent != '' && !is_dir($verifyDir)){
111: switch (self::$fileOper) {
112: case 'ftp':
113: if (!@ftp_mkdir(self::$_ftpStream, self::ftpStripChroot($parent)))
114: throw new Exception("Failed to create directory $parent", self::ERR_FTPOPER);
115: break;
116: case 'php':
117: default:
118: if(!@mkdir($parent))
119: throw new Exception("Failed to create directory $parent");
120: }
121: }
122: }
123:
124: if($remote){
125: if(self::tryCurl($source)){
126: // Fall back on the getContents method, which will try using CURL
127: $ch = self::curlInit($source);
128: $contents = curl_exec($ch);
129: if((bool) $contents)
130: return @file_put_contents($target, $contents) !== false;
131: else
132: return false;
133: } else{
134: $context = stream_context_create(array(
135: 'http' => array(
136: 'timeout' => 15 // Timeout in seconds
137: )));
138: return @copy($source, $target, $context) !== false;
139: }
140: }else{
141: // Recursively copy a whole folder
142: $source = self::rpath($source);
143: if(!is_dir($source) && !file_exists($source))
144: throw new Exception("Source file/directory to be copied ($source) not found.");
145:
146: if(is_dir($source)){
147: if(!$contents){
148: // Append the bottom level node in the source path to the
149: // target path.
150: //
151: // This ensures that we copy in the aptly-named target
152: // directory instead of dumping the contents of the source
153: // into the designated target.
154: $source = rtrim($source, $ds);
155: $sourceNodes = explode($ds, $source);
156: $target = $target.$ds.array_pop($sourceNodes);
157: }
158: if(!is_dir($target)){
159: switch (self::$fileOper) {
160: case 'ftp':
161: if (!@ftp_mkdir(self::$_ftpStream, self::ftpStripChroot($target)))
162: throw new Exception("Unable to create directory $target", self::ERR_FTPOPER);
163: break;
164: case 'php':
165: default:
166: mkdir($target);
167: }
168: }
169: $return = true;
170: $files = scandir($source);
171: foreach($files as $file){
172: if($file != '.' && $file != '..'){
173: // Must be recursively called with $relTarget = false
174: // because if ccopy is called with $relTarget = true,
175: // then at this stage "$target" is already relative,
176: // and the argument passed to relpath must be absolute.
177: // It also must be called with contents=true because
178: // that option, if enabled at lower levels, will create
179: // the parent directory twice.
180: $return = $return && FileUtil::ccopy($source.$ds.$file, $target.$ds.$file);
181: }
182: }
183: return $return;
184: }else{
185: switch (self::$fileOper) {
186: case 'ftp':
187: return @ftp_put(self::$_ftpStream, self::ftpStripChroot($target), $source, FTP_BINARY);
188: case 'php':
189: default:
190: $retVal = @copy($source, $target) !== false;
191: self::caseInsensitiveCopyFix ($source, $target);
192: return $retVal;
193: }
194: }
195: }
196: }
197:
198: /**
199: * To be called after copying a file. If it's the case that source and target basenames differ
200: * by case, target will be renamed so that its basename matches the source's. Allows case
201: * of source filename to be preserved in case insensitive file systems.
202: * @return bool false if rename wasn't called, true otherwise (value used for testing purposes)
203: */
204: private static function caseInsensitiveCopyFix ($source, $target) {
205: $sourceBasename = basename ($source);
206: $targetBasename = basename ($target);
207: // if basename of source and target params aren't the same, it means that case was changed
208: // explicitly
209: if ($sourceBasename !== $targetBasename) return false;
210:
211: // get path to file corresponding to target, so that we can get the basename of the actual
212: // file
213: $target = realpath ($target);
214: if (!$target) return false;
215:
216: $targetBasename = basename ($target);
217:
218: // source and target have the same case so renaming won't be necessary
219: if ($targetBasename === $sourceBasename ||
220: // or source and target base name differ by something other than case
221: strtolower ($targetBasename) !== strtolower ($sourceBasename)) {
222:
223: return false;
224: }
225:
226: // replace target basename with source basename
227: $newTargetName = preg_replace (
228: '/'.preg_quote ($targetBasename).'$/', $sourceBasename, $target);
229: if ($newTargetName !== $target) {
230: @rename ($target, $newTargetName);
231: return true;
232: }
233: return false;
234: }
235:
236: /**
237: * Change to a given directory relative to the FTP stream's
238: * current working directory
239: * @param string $target
240: */
241: public static function cdftp($target) {
242: $target = self::ftpStripChroot($target);
243: $src = ftp_pwd(self::$_ftpStream);
244: if ($src === '/')
245: $cd = $target;
246: else {
247: $cd = self::relpath($target, $src . DIRECTORY_SEPARATOR);
248: if (empty($cd))
249: return;
250: }
251: if (!@ftp_chdir(self::$_ftpStream, $cd))
252: throw new Exception("Unable to change to directory '$cd' from '$src'", self::ERR_FTPOPER);
253: }
254:
255: /**
256: * Removes DOS-related junk from an absolute path.
257: *
258: * Returns the path as an array of nodes.
259: */
260: public static function cleanDosPath($path){
261: $a_dirty = explode('\\', $path);
262: $a = array();
263: foreach($a_dirty as $node){
264: $a[] = $node;
265: }
266: $lastNode = array_pop($a);
267: if(preg_match('%/%', $lastNode)){
268: // The final part of the path might contain a relative path put
269: // together with forward slashes (for the lazy developer)
270: foreach(explode('/', $lastNode) as $node)
271: $a[] = $node;
272: }else{
273: $a[] = $lastNode;
274: }
275: return $a;
276: }
277:
278:
279: /**
280: * Create a lockfile using the appropriate functionality, depending
281: * on the selected file operation.
282: * @param String $lockfile
283: */
284: public static function createLockfile($lockfile) {
285: switch (self::$fileOper) {
286: case 'ftp':
287: $stream = fopen('data://text/plain,'.time(), 'r');
288: if (!@ftp_fput(self::$_ftpStream, self::ftpStripChroot($lockfile), $stream, FTP_BINARY))
289: throw new Exception("Unable to create lockfile $lockfile", self::ERR_FTPOPER);
290: fclose($stream);
291: break;
292: case 'php':
293: default:
294: file_put_contents($lockfile, time());
295: }
296: }
297:
298: /**
299: * Initializes and returns a CURL resource handle
300: * @param string $url
301: * @return resource
302: */
303: public static function curlInit($url){
304: $ch = curl_init($url);
305: $curlopt = array(
306: CURLOPT_RETURNTRANSFER => 1,
307: CURLOPT_RETURNTRANSFER => 1,
308: CURLOPT_BINARYTRANSFER => 1,
309: CURLOPT_POST => 0,
310: CURLOPT_TIMEOUT => 15
311: );
312: curl_setopt_array($ch, $curlopt);
313: return $ch;
314: }
315:
316: /**
317: * Closes the current FTP stream and resets the file
318: * operation method to 'php'
319: */
320: public static function ftpClose() {
321: ftp_close(self::$_ftpStream);
322: self::$ftpChroot = false;
323: self::$fileOper = 'php';
324: }
325:
326: /**
327: * Initializes the FTP functionality. This connects to
328: * the FTP server, logs in, and sets PASV mode.
329: * Optionally, you can specify the chroot directory and a directory to
330: * change to, e.g.: the web root or the test directory. This is recommended
331: * if working with relative paths.
332: * @param String $host The FTP server to connect to
333: * @param String $user The FTP user.
334: * @param String $pass Specified FTP user's password.
335: * @param String $dir Initial directory to change to, or null by default
336: * to disable.
337: * @param String $chroot The chosen chroot directory for the user.
338: */
339: public static function ftpInit($host, $user, $pass, $dir = null, $chroot = null) {
340: if (!self::$_ftpStream = ftp_connect($host))
341: throw new Exception("The updater is unable to connect to $host. Please check your FTP connection settings.", self::ERR_FTPOPER);
342: if (!@ftp_login(self::$_ftpStream, $user, $pass))
343: throw new Exception("Unable to login as user $user", self::ERR_FTPOPER);
344: ftp_pasv(self::$_ftpStream, true);
345: if ($chroot !== null)
346: self::$ftpChroot = $chroot;
347: if ($dir !== null)
348: self::cdftp($dir);
349: self::$fileOper = "ftp";
350: }
351:
352: public static function ftpStripChroot($dir) {
353: if (self::$ftpChroot === false || self::isRelative($dir)) // Don't modify a relative path
354: return $dir;
355: else {
356: $replaced = str_replace(self::$ftpChroot, '', $dir);
357: // Add a leading slash if missing
358: if (!preg_match('/^(\/|\\\)/', $replaced))
359: $replaced = DIRECTORY_SEPARATOR.$replaced;
360: return $replaced;
361: }
362: }
363:
364: /**
365: * Wrapper for file_get_contents that attempts to use CURL if allow_url_fopen is disabled.
366: *
367: * @param type $source
368: * @param type $url
369: * @return type
370: * @throws Exception
371: */
372: public static function getContents($source, $use_include_path = false, $context = null){
373: if(self::tryCurl($source)){
374: $ch = self::curlInit($source);
375: return @curl_exec($ch);
376: }else{
377: // Use the usual copy method
378: return @file_get_contents($source, $use_include_path, $context);
379: }
380: }
381:
382: /**
383: * Returns whether the given parameter is a relative path
384: * @param string $path
385: * @return boolean Whether the path is relative
386: */
387: public static function isRelative($path) {
388: // Paths that start with .. or a word character, but not a Windows
389: // drive specification (C:\).
390: return preg_match('/^\.\./', $path) || preg_match('/^\w[^:]/', $path);
391: }
392:
393: /**
394: * Removes redundant up-one-level directory traversal from a path.
395: *
396: * Returns an array corresponding to each node in the path, with redundant
397: * directory traversal removed. For example, "items/files/stuff/../things"
398: * will be returned as array("items","files","things"). Note, however, that
399: * "../../stuff/things" will be returned as array("stuff","things"), which
400: * does not accurately reflect the actual path from the original working
401: * directory. The intention of this function was to clean up absolute paths.
402: * @param array $path Path to clean
403: */
404: public static function noTraversal($path){
405: $p2 = array();
406: foreach($path as $node){
407: if($node == '..'){
408: if(count($p2) > 0)
409: array_pop($p2);
410: } else{
411: $p2[] = $node;
412: }
413: }
414: return $p2;
415: }
416:
417: /**
418: * Remove a lockfile using the appropriate functionality, depending
419: * on the selected file operation.
420: * @param String $lockfile
421: */
422: public static function removeLockfile($lockfile) {
423: switch (self::$fileOper) {
424: case 'ftp':
425: $lockfile = self::ftpStripChroot($lockfile);
426: if (!@ftp_delete(self::$_ftpStream, $lockfile))
427: throw new Exception("Unable to delete the lockfile $lockfile", self::ERR_FTPOPER);
428: break;
429: case 'php':
430: default:
431: unlink($lockfile);
432: }
433: }
434:
435: /**
436: * Format a path so that it is platform-independent. Doesn't return false
437: * if the path doesn't exist (so unlike realpath() it can be used to create
438: * new files).
439: *
440: * @param string $path
441: * @return string
442: */
443: public static function rpath($path){
444: return implode(DIRECTORY_SEPARATOR, explode('/', $path));
445: }
446:
447: /**
448: * Calculates a relative path between two absolute paths.
449: *
450: * @param string $path The path to which the absolute path should be calculated.
451: * @param string $start The starting path. Must be absolute, if specified, and
452: * must be specified if the path argument is not platform-agnostic.
453: * @param string $ds Directory separator. If the two paths (path and start)
454: * use the (almost) ubiquitous convention of forward slashes, but the
455: * calculation is to take place on a Windows machine, this must be set to
456: * forward slash, so that
457: */
458: public static function relpath($path, $start = null, $ds = DIRECTORY_SEPARATOR){
459: $thisPath = $start === null ? realpath('.').$ds : $start;
460: // Get node arrays for each path:
461: if(preg_match('/^([A-Z]):\\\\/', $thisPath, $match0)){ // Windows environment
462: if(preg_match('/([A-Z]):\\\\/', $path, $match1)){ // Target path is absolute
463: if($match0[1] != $match1[1]) // Source and destination are on different drives. Regurgitate the absolute path.
464: return $path;
465: else{ // Source/destination on same drive.
466: $a1 = self::cleanDosPath($path);
467: array_shift($a1);
468: $a1 = self::noTraversal($a1);
469: }
470: }else{ // Target path is relative
471: $a1 = self::noTraversal(explode($ds, $path));
472: }
473: $a0 = self::cleanDosPath($thisPath);
474: array_shift($a0);
475: $a0 = self::noTraversal($a0);
476: array_pop($a0);
477: }else{ // Unix environment. SO MUCH EASIER.
478: $a0 = self::noTraversal(explode($ds, $thisPath));
479: array_pop($a0);
480: $a1 = self::noTraversal(explode($ds, $path));
481: }
482: // Find out what the paths have in common and calculate the number of levels to traverse up:
483: $l = 0;
484: while($l < count($a0) && $l < count($a1)){
485: if($a0[$l] != $a1[$l])
486: break;
487: $l++;
488: }
489: $lUp = count($a0) - $l;
490: return str_repeat('..'.$ds, $lUp).implode($ds, array_slice($a1, $l));
491: }
492:
493: /**
494: * Recursively remove a directory and all its subdirectories.
495: *
496: * Walks a directory structure, removing files recursively. An optional
497: * exclusion pattern can be included. If a directory contains a file that
498: * matches the exclusion pattern, the directory and its ancestors will not
499: * be deleted.
500: *
501: * @param string $path
502: * @param string $noDelPat PCRE pattern for excluding files in deletion.
503: */
504: public static function rrmdir($path, $noDelPat = null){
505: $useExclude = $noDelPat != null;
506: $special = '/.*\/?\.+\/?$/';
507: $excluded = false;
508: if(!realpath($path))
509: return false;
510: $path = realpath($path);
511: if(filetype($path) == 'dir'){
512: $objects = scandir($path);
513: foreach($objects as $object){
514: if(!preg_match($special, $object)){
515: if($useExclude){
516: if(!preg_match($noDelPat, $object)){
517: $excludeThis = self::rrmdir($path.DIRECTORY_SEPARATOR.$object, $noDelPat);
518: $excluded = $excluded || $excludeThis;
519: }else{
520: $excluded = true;
521: }
522: } else
523: self::rrmdir($path.DIRECTORY_SEPARATOR.$object, $noDelPat);
524: }
525: }
526: reset($objects);
527: if(!$excluded)
528: if(!preg_match($special, $path))
529: switch (self::$fileOper) {
530: case 'ftp':
531: $path = self::ftpStripChroot($path);
532: ftp_rmdir(self::$_ftpStream, $path);
533: break;
534: case 'php':
535: default:
536: rmdir($path);
537: }
538: } else
539: switch (self::$fileOper) {
540: case 'ftp':
541: $path = self::ftpStripChroot($path);
542: ftp_delete(self::$_ftpStream, $path);
543: break;
544: case 'php':
545: default:
546: unlink($path);
547: }
548: return $excluded;
549: }
550:
551: /**
552: * Create/return finfo resource handle
553: *
554: * @return resource
555: */
556: public static function finfo(){
557: if(!isset(self::$_finfo))
558: if(extension_loaded('fileinfo'))
559: self::$_finfo = finfo_open();
560: else
561: self::$_finfo = false;
562: return self::$_finfo;
563: }
564:
565: /**
566: * Create human-readable size string
567: *
568: * @param type $bytes
569: * @return type
570: */
571: public static function formatSize($bytes, $places = 0){
572: $sz = array('', 'K', 'M', 'G', 'T', 'P');
573: $factor = floor((strlen($bytes) - 1) / 3);
574: return sprintf("%.{$places}f ", $bytes / pow(1024, $factor)).@$sz[$factor]."B";
575: }
576:
577: /**
578: * The criteria for which CURL should be used.
579: * @return type
580: */
581: public static function tryCurl($source){
582: $try = preg_match('%^https?://%', $source) && (ini_get('allow_url_fopen') == 0 || self::$alwaysCurl);
583: if($try)
584: if(!extension_loaded('curl'))
585: throw new Exception('No HTTP methods available. Tried accessing a remote object, but allow_url_fopen is not enabled and the CURL extension is missing.', 500);
586: return $try;
587: }
588:
589: }
590:
591: ?>
592: