From 6cf998c3a055d534b625a41626362d3716366295 Mon Sep 17 00:00:00 2001 From: Ryan Cramer Date: Wed, 5 May 2021 13:53:07 -0400 Subject: [PATCH] Improvements to WireFileTools, lots with error message handling and logging, addition of a renameCopy() method, and other minor improvements. --- wire/core/WireFileTools.php | 273 +++++++++++++++++++++++++----------- 1 file changed, 195 insertions(+), 78 deletions(-) diff --git a/wire/core/WireFileTools.php b/wire/core/WireFileTools.php index 2469855e..c8d66f93 100644 --- a/wire/core/WireFileTools.php +++ b/wire/core/WireFileTools.php @@ -5,7 +5,7 @@ * * #pw-summary Helpers for working with files and directories. * - * ProcessWire 3.x, Copyright 2020 by Ryan Cramer + * ProcessWire 3.x, Copyright 2021 by Ryan Cramer * https://processwire.com * * @method bool include($filename, array $vars = array(), array $options = array()) @@ -52,7 +52,9 @@ class WireFileTools extends Wire { $parentPath = substr($path, 0, strrpos(rtrim($path, '/'), '/')); if(!is_dir($parentPath) && !$this->mkdir($parentPath, true, $chmod)) return false; } - if(!@mkdir($path)) return false; + if(!@mkdir($path)) { + return $this->filesError(__FUNCTION__, "Unable to mkdir $path"); + } } $this->chmod($path, false, $chmod); return true; @@ -113,7 +115,11 @@ class WireFileTools extends Wire { } } - return @rmdir($path); + if(@rmdir($path)) { + return true; + } else { + return $this->filesError(__FUNCTION__, "Unable to rmdir: $path", $options); + } } @@ -152,11 +158,13 @@ class WireFileTools extends Wire { if(is_null($chmod)) { // default: pull values from PW config - $chmodFile = $this->wire('config')->chmodFile; - $chmodDir = $this->wire('config')->chmodDir; + $chmodFile = $this->wire()->config->chmodFile; + $chmodDir = $this->wire()->config->chmodDir; } else { // optional, manually specified string - if(!is_string($chmod)) throw new WireException("chmod must be specified as a string like '0755'"); + if(!is_string($chmod)) { + $this->filesException(__FUNCTION__, "chmod must be specified as a string like '0755'"); + } $chmodFile = $chmod; $chmodDir = $chmod; } @@ -288,8 +296,8 @@ class WireFileTools extends Wire { * - This method requires a full pathname to a file to unlink and does not * accept any kind of relative path traversal. * - * - This method will only unlink files in /site/assets/ if you specify `true` - * for the `$limitPath` option (recommended). + * - This method will be limited to unlink files only in /site/assets/ if you + * specify `true` for the `$limitPath` option (recommended). * * @param string $filename * @param string|bool $limitPath Limit only to files within some starting path? (default=false) @@ -312,14 +320,13 @@ class WireFileTools extends Wire { if(!is_file($filename) && !is_link($filename)) { // only files or links (that exist) can be deleted - return false; + return $this->filesError(__FUNCTION__, "Given filename is not a file or link: $filename"); } if(@unlink($filename)) { return true; } else { - if($throw) throw new WireException("Unable to unlink file $filename"); - return false; + return $this->filesError(__FUNCTION__, "Unable to unlink file: $filename", $throw); } } @@ -334,6 +341,8 @@ class WireFileTools extends Wire { * - `limitPath` (bool|string|array): Limit renames to within this path, or boolean TRUE for site/assets, or FALSE to disable (default=false). * - `throw` (bool): Throw WireException with verbose details on error? (default=false) * - `chmod` (bool): Adjust permissions to be consistent with $config after rename? (default=true) + * - `copy` (bool): Use copy-then-delete method rather than a file system rename. (default=false) 3.0.178+ + * - `retry` (bool): Retry with 'copy' method if regular rename files, applies only if copy option is false. (default=true) 3.0.178+ * - If given a bool or string for $options the `limitPath` option is assumed. * @return bool True on success, false on fail (or WireException if throw option specified). * @throws WireException If error occurs and $throw argument was true. @@ -346,6 +355,8 @@ class WireFileTools extends Wire { 'limitPath' => false, 'throw' => false, 'chmod' => true, + 'copy' => false, + 'retry' => true, ); if(!is_array($options)) $options = array('limitPath' => $options); @@ -359,36 +370,79 @@ class WireFileTools extends Wire { try { $this->allowPath($oldName, $options['limitPath'], true); } catch(\Exception $e) { - if($options['throw']) throw new WireException("Rename oldName path invalid: " . $e->getMessage()); - return false; + return $this->filesError(__FUNCTION__, '$oldName path invalid: ' . $e->getMessage(), $options); } try { $this->allowPath($newName, $options['limitPath'], true); } catch(\Exception $e) { - if($options['throw']) throw new WireException("Rename newName path invalid: " . $e->getMessage()); - return false; + return $this->filesError(__FUNCTION__, 'Rename $newName path invalid: ' . $e->getMessage(), $options); } if(!file_exists($oldName)) { - if($options['throw']) throw new WireException("Rename given pathname (oldName) that does not exist: $oldName"); - return false; + return $this->filesError(__FUNCTION__, 'Given pathname ($oldName) that does not exist: ' . $oldName, $options); } if(file_exists($newName)) { - if($options['throw']) throw new WireException("Rename to pathname (newName) that already exists: $newName"); - return false; + return $this->filesError(__FUNCTION__, 'Rename to pathname ($newName) that already exists: ' . $newName, $options); } - - if(!rename($oldName, $newName)) { - if($options['throw']) throw new WireException("Rename failed: $oldName => $newName"); + + if($options['copy']) { + // will use recursive copy method only + $success = false; + } else if($options['retry']) { + // consider any error/warnings from rename a non-event since we can retry + $success = @rename($oldName, $newName); + } else { + // use real rename only + $success = rename($oldName, $newName); } - if($options['chmod']) { - $this->chmod($newName); + if(!$success && ($options['retry'] || $options['copy'])) { + $opt = array( + 'limitPath' => $options['limitPath'], + 'throw' => $options['throw'], + ); + if($this->copy($oldName, $newName, $opt)) { + $success = true; + if(is_dir($oldName)) { + if(!$this->rmdir($oldName, true, $opt)) { + $this->filesError(__FUNCTION__, 'Unable to rmdir source ($oldName): ' . $oldName); + } + } else { + if(!$this->unlink($oldName, $opt['limitPath'], $opt['throw'])) { + $this->filesError(__FUNCTION__, 'Unable to unlink source ($oldName): ' . $oldName); + } + } + } + } + + if($success) { + if($options['chmod']) $this->chmod($newName); + } else { + $this->filesError(__FUNCTION__, "Failed: $oldName => $newName", $options); } - return true; + return $success; + } + + /** + * Rename by first copying files to destination and then deleting source files + * + * The operation is considered successful so long as the source files were able to be copied to the destination. + * If source files cannot be deleted afterwards, the warning is logged, plus a warning notice is also shown when in debug mode. + * + * @param string $oldName Old pathname, must be full disk path. + * @param string $newName New pathname, must be full disk path OR can be basename to assume same path as $oldName. + * @param array $options See options for rename() method + * @return bool + * @throws WireException + * @since 3.0.178 + * + */ + public function renameCopy($oldName, $newName, $options = array()) { + $options['copy'] = true; + return $this->rename($oldName, $newName, $options); } /** @@ -424,34 +478,31 @@ class WireFileTools extends Wire { $allow = $this->allowPath($pathname, $dir, false); if($allow) break; // found one that is allowed } - if(!$allow && $throw) { - throw new WireException("Given pathname is not within any of the paths allowed by limitPath"); + if(!$allow) { + $this->filesError(__FUNCTION__, "Given pathname is not within any of the paths allowed by limitPath", $throw); } return $allow; } else if($limitPath === true) { // default limitPath - $limitPath = $this->wire('config')->paths->assets; + $limitPath = $this->wire()->config->paths->assets; } else if($limitPath === false) { // no limitPath in use } else if(empty($limitPath) || !is_string($limitPath)) { // invalid limitPath argument (wrong type or path does not exist) - if($throw) throw new WireException("Invalid type for limitPath argument"); - return false; + return $this->filesError(__FUNCTION__, "Invalid type for limitPath argument", $throw); } else if(!is_dir($limitPath)) { - if($throw) throw new WireException("$limitPath (limitPath) does not exist"); - return false; + return $this->filesError(__FUNCTION__, "$limitPath (limitPath) does not exist", $throw); } if($limitPath !== false) try { // if limitPath can't pass allowPath then neither can $pathname $this->allowPath($limitPath, false, true); } catch(\Exception $e) { - if($throw) throw new WireException("Validating limitPath reported: " . $e->getMessage()); - return false; + return $this->filesError(__FUNCTION__, "Validating limitPath reported: " . $e->getMessage(), $throw, $e); } if(DIRECTORY_SEPARATOR != '/') { @@ -466,32 +517,27 @@ class WireFileTools extends Wire { if(!strlen(trim($testname, '/.')) || substr_count($testname, '/') < 2) { // do not allow paths that consist of nothing but slashes and/or dots // and do not allow paths off root or lacking absolute path reference - if($throw) throw new WireException("pathname not allowed: $pathname"); - return false; + return $this->filesError(__FUNCTION__, "pathname not allowed: $pathname", $throw); } if(strpos($pathname, '..') !== false) { // not allowed to traverse anywhere - if($throw) throw new WireException('pathname may not traverse “../”'); - return false; + return $this->filesError(__FUNCTION__, 'pathname may not traverse “../”', $throw); } if(strpos($pathname, '.') === 0 || empty($pathname)) { - if($throw) throw new WireException('pathname may not begin with “.”'); - return false; + return $this->filesError(__FUNCTION__, 'pathname may not begin with “.”', $throw); } $pos = strpos($pathname, '//'); if($pos !== false && $pos !== strpos($this->wire('config')->paths->assets, '//')) { // URLs or accidental extra slashes not allowed, unless they also appear in a known safe system path - if($throw) throw new WireException('pathname may not contain double slash “//”'); - return false; + return $this->filesError(__FUNCTION__, 'pathname may not contain double slash “//”', $throw); } if($limitPath !== false && strpos($pathname, $limitPath) !== 0) { // disallow paths that do not begin with limitPath (i.e. /path/to/public_html/site/assets/) - if($throw) throw new WireException("Given pathname is not within $limitPath (limitPath)"); - return false; + return $this->filesError(__FUNCTION__, "Given pathname is not within $limitPath (limitPath)", $throw); } return true; @@ -500,33 +546,40 @@ class WireFileTools extends Wire { /** * Return a new temporary directory/path ready to use for files * - * The directory will be automatically removed after a set period of time (default=120s) + * - The temporary directory will be automatically removed at the end of the request. + * - Temporary directories are not http accessible. + * - If you call this with the same non-empty `$name` argument more than once in the + * same request, the same `WireTempDir` instance will be returned. * * #pw-advanced * * ~~~~~ - * $td = $files->tempDir('hello-world'); - * $path = (string) $td; // or use $td->get(); + * $tempDir = $files->tempDir(); + * $path = $tempDir->get(); // or use $td->get(); * file_put_contents($path . 'some-file.txt', 'Hello world'); * ~~~~~ * - * @param Object|string $name Provide the object that needs the temp dir, or name your own string - * @param array|int $options Options array to modify default behavior: - * - `maxAge` (integer): Maximum age of temp dir files in seconds (default=120) - * - `basePath` (string): Base path where temp dirs should be created. Omit to use default (recommended). - * - Note: if you specify an integer for $options, then 'maxAge' is assumed. - * @return WireTempDir If you typecast return value to a string, it is the temp dir path (with trailing slash). + * @param Object|string $name Any one of the following: (default='') + * - Omit this argument for auto-generated name, 3.0.175+ + * - Name/word that you specify using fieldName format, i.e. [_a-zA-Z0-9]. + * - Object instance that needs the temp dir. + * @param array|int $options Deprecated argument. Call `WireTempDir` methods if you need more options. + * @return WireTempDir Returns a WireTempDir instance. If you typecast return value to a string, + * it is the temp dir path (with trailing slash). * @see WireTempDir * */ - public function tempDir($name, $options = array()) { + public function tempDir($name = '', $options = array()) { static $tempDirs = array(); - if(isset($tempDirs[$name])) return $tempDirs[$name]; + if($name && isset($tempDirs[$name])) return $tempDirs[$name]; if(is_int($options)) $options = array('maxAge' => $options); $basePath = isset($options['basePath']) ? $options['basePath'] : ''; - $tempDir = new WireTempDir($name, $basePath); + $tempDir = new WireTempDir(); + $this->wire($tempDir); + if(isset($options['remove']) && $options['remove'] === false) $tempDir->setRemove(false); + $tempDir->init($name, $basePath); if(isset($options['maxAge'])) $tempDir->setMaxAge($options['maxAge']); - $tempDirs[$name] = $tempDir; + if($name) $tempDirs[$name] = $tempDir; return $tempDir; } @@ -630,17 +683,17 @@ class WireFileTools extends Wire { $dst = rtrim($dst, '/' . DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; - if(!class_exists('\ZipArchive')) throw new WireException("PHP's ZipArchive class does not exist"); - if(!is_file($file)) throw new WireException("ZIP file does not exist"); + if(!class_exists('\ZipArchive')) $this->filesException(__FUNCTION__, "PHP's ZipArchive class does not exist"); + if(!is_file($file)) $this->filesException(__FUNCTION__, "ZIP file does not exist"); if(!is_dir($dst)) $this->mkdir($dst, true); $names = array(); - $chmodFile = $this->wire('config')->chmodFile; - $chmodDir = $this->wire('config')->chmodDir; + $chmodFile = $this->wire()->config->chmodFile; + $chmodDir = $this->wire()->config->chmodDir; $zip = new \ZipArchive(); $res = $zip->open($file); - if($res !== true) throw new WireException("Unable to open ZIP file, error code: $res"); + if($res !== true) $this->filesException(__FUNCTION__, "Unable to open ZIP file, error code: $res"); for($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); @@ -728,21 +781,22 @@ class WireFileTools extends Wire { $zip = $options['zip']; // ZipArchive instance } else if(is_string($zipfile)) { - if(!class_exists('\ZipArchive')) throw new WireException("PHP's ZipArchive class does not exist"); + if(!class_exists('\ZipArchive')) $this->filesException(__FUNCTION__, "PHP's ZipArchive class does not exist"); $options = array_merge($defaults, $options); $zippath = dirname($zipfile); - if(!is_dir($zippath)) throw new WireException("Path for ZIP file ($zippath) does not exist"); - if(!is_writable($zippath)) throw new WireException("Path for ZIP file ($zippath) is not writable"); - if(empty($files)) throw new WireException("Nothing to add to ZIP file $zipfile"); - if(is_file($zipfile) && $options['overwrite'] && !$this->unlink($zipfile)) throw new WireException("Unable to overwrite $zipfile"); + if(!is_dir($zippath)) $this->filesException(__FUNCTION__, "Path for ZIP file ($zippath) does not exist"); + if(!is_writable($zippath)) $this->filesException(__FUNCTION__, "Path for ZIP file ($zippath) is not writable"); + if(empty($files)) $this->filesException(__FUNCTION__, "Nothing to add to ZIP file $zipfile"); + if(is_file($zipfile) && $options['overwrite'] && !$this->unlink($zipfile)) $this->filesException(__FUNCTION__, "Unable to overwrite $zipfile"); if(!is_array($files)) $files = array($files); if(!is_array($options['exclude'])) $options['exclude'] = array($options['exclude']); $recursive = false; $zip = new \ZipArchive(); - if($zip->open($zipfile, \ZipArchive::CREATE) !== true) throw new WireException("Unable to create ZIP: $zipfile"); + if($zip->open($zipfile, \ZipArchive::CREATE) !== true) $this->filesException(__FUNCTION__, "Unable to create ZIP: $zipfile"); } else { - throw new WireException("Invalid zipfile argument"); + $this->filesException(__FUNCTION__, "Invalid zipfile argument"); + return array(); // not reachable } $dir = strlen($options['dir']) ? rtrim($options['dir'], '/') . '/' : ''; @@ -833,7 +887,7 @@ class WireFileTools extends Wire { try { $result = $http->sendFile($filename, $options, $headers); } catch(\Exception $e) { - if($options['throw']) throw $e; + $this->filesError(__FUNCTION__, $e->getMessage(), $options, $e); $result = 0; } return $result; @@ -858,7 +912,11 @@ class WireFileTools extends Wire { public function filePutContents($filename, $contents, $flags = 0) { $this->allowPath($filename, false, true); $result = file_put_contents($filename, $contents, $flags); - if($result !== false) $this->chmod($filename); + if($result === false) { + $this->filesError(__FUNCTION__, "Unable to write: $filename"); + } else { + $this->chmod($filename); + } return $result; } @@ -925,7 +983,7 @@ class WireFileTools extends Wire { */ public function render($filename, array $vars = array(), array $options = array()) { - $paths = $this->wire('config')->paths; + $paths = $this->wire()->config->paths; $defaults = array( 'defaultPath' => $paths->templates, 'autoExtension' => 'php', @@ -952,7 +1010,7 @@ class WireFileTools extends Wire { if(!$options['allowDotDot'] && strpos($filename, '..')) { // make path relative to /site/templates/ if filename is not an absolute path $error = 'Filename may not have ".."'; - if($options['throwExceptions']) throw new WireException($error); + if($options['throwExceptions']) $this->filesException(__FUNCTION__, $error); $this->error($error); return false; } @@ -976,7 +1034,7 @@ class WireFileTools extends Wire { if(!$allowed) { $error = "Filename $filename is not in an allowed path." ; $error .= ' Paths: ' . implode("\n", $options['allowedPaths']) . ''; - if($options['throwExceptions']) throw new WireException($error); + if($options['throwExceptions']) $this->filesException(__FUNCTION__, $error); $this->error($error); return false; } @@ -1055,13 +1113,13 @@ class WireFileTools extends Wire { // if backtrack/relative components, convert to real path $_filename = $filename; $filename = realpath($filename); - if($filename === false) throw new WireException("File does not exist: $_filename"); + if($filename === false) $this->filesException(__FUNCTION__, "File does not exist: $_filename"); } $filename = $this->unixFileName($filename); if(strpos($filename, '//') !== false) { - throw new WireException("File is not allowed (double-slash): $filename"); + $this->filesException(__FUNCTION__, "File is not allowed (double-slash): $filename"); } if(strpos($filename, './') !== 0) { @@ -1077,10 +1135,10 @@ class WireFileTools extends Wire { foreach($options['allowedPaths'] as $path) { if($this->fileInPath($filename, $path)) $allowed = true; } - if(!$allowed) throw new WireException("File is not in an allowed path: $filename"); + if(!$allowed) $this->filesException(__FUNCTION__, "File is not in an allowed path: $filename"); } - if(!file_exists($filename)) throw new WireException("File does not exist: $filename"); + if(!file_exists($filename)) $this->filesException(__FUNCTION__, "File does not exist: $filename"); // extract all API vars $fuel = array_merge($this->wire('fuel')->getArray(), $vars); @@ -1435,4 +1493,63 @@ class WireFileTools extends Wire { return $this->unixDirName(getcwd()); } + /** + * Report/log/throw an error + * + * #pw-internal + * + * @param string $method + * @param string $msg + * @param bool|array $throw Throw exception? May be boolean or array with 'throw' index containing boolean. + * @param \Exception|null $e Previous exception, if applicable + * @return bool Always returns boolean false (so it can be used in error return statements) + * @throws WireFilesException + * @since 3.0.178 + * + */ + public function filesError($method, $msg, $throw = false, $e = null) { + if(is_array($throw)) $throw = isset($throw['throw']) ? $throw['throw'] : false; + $msg = "$method: $msg"; + $this->log($msg, array('name' => 'files-errors')); + if($throw) { + if($e) throw new WireFilesException($msg, $e->getCode(), $e); + throw new WireFilesException($msg); + } else if($this->wire()->config->debug) { + $this->warning($msg, Notice::debug); + } + return false; + } + + /** + * Throw a files exception + * + * #pw-internal + * + * @param string $method + * @param string $msg + * @param \Exception|null $e + * @throws WireFilesException + * @since 3.0.178 + * + */ + public function filesException($method, $msg, $e = null) { + $this->filesError($method, $msg, true, $e); + } + + /** + * Log a message for this class + * + * #pw-internal + * + * @param string $str Text to log, or omit to return the `$log` API variable. + * @param array $options Optional extras to include, see Wire::___log() + * @return WireLog + * + */ + public function ___log($str = '', array $options = array()) { + if(empty($options['name'])) $options['name'] = 'files'; + return parent::___log($str, $options); + } + + } \ No newline at end of file