* @package tao */ use oat\oatbox\filesystem\File; class tao_helpers_File extends helpers_File { public const MIME_SVG = 'image/svg+xml'; /** * Check if the path in parameter can be securly used into the application. * (check the cross directory injection, the null byte injection, etc.) * Use it when the path may be build from a user variable * * @author Lionel Lecaque, * @param string $path The path to check. * @param boolean $traversalSafe (optional, default is false) Check if the path is traversal safe. * @return boolean States if the path is secure or not. */ public static function securityCheck($path, $traversalSafe = false) { $returnValue = true; //security check: detect directory traversal (deny the ../) if ($traversalSafe) { if (preg_match("/\.\.\//", $path)) { $returnValue = false; common_Logger::w('directory traversal detected in ' . $path); } } //security check: detect the null byte poison by finding the null char injection if ($returnValue) { for ($i = 0; $i < strlen($path); $i++) { if (ord($path[$i]) === 0) { $returnValue = false; common_Logger::w('null char injection detected in ' . $path); break; } } } return (bool) $returnValue; } /** * Use this method to cleanly concat components of a path. It will remove extra slashes/backslashes. * * @author Lionel Lecaque, * @param array $paths The path components to concatenate. * @return string The concatenated path. */ public static function concat($paths) { $returnValue = (string) ''; foreach ($paths as $path) { if (!preg_match("/\/$/", $returnValue) && !preg_match("/^\//", $path) && !empty($returnValue)) { $returnValue .= '/'; } $returnValue .= $path; } $returnValue = str_replace('//', '/', $returnValue); return (string) $returnValue; } /** * Remove a file. If the recursive parameter is set to true, the target file * can be a directory that contains data. * * @author Lionel Lecaque, * @param string $path The path to the file you want to remove. * @param boolean $recursive (optional, default is false) Remove file content recursively (only if the path points to a directory). * @return boolean Return true if the file is correctly removed, false otherwise. */ public static function remove($path, $recursive = false) { $returnValue = (bool) false; if ($recursive) { $returnValue = helpers_File::remove($path); } elseif (is_file($path)) { $returnValue = @unlink($path); } // else fail silently return (bool) $returnValue; } /** * Move file from source to destination. * * @author Lionel Lecaque, * @param string $source A path to the source file. * @param string $destination A path to the destination file. * @return boolean Returns true if the file was successfully moved, false otherwise. */ public static function move($source, $destination) { $returnValue = (bool) false; if (is_dir($source)) { if (!file_exists($destination)) { mkdir($destination, 0777, true); } $error = false; foreach (scandir($source) as $file) { if ($file != '.' && $file != '..') { if (is_dir($source . '/' . $file)) { if (!self::move($source . '/' . $file, $destination . '/' . $file, true)) { $error = true; } } else { if (!self::copy($source . '/' . $file, $destination . '/' . $file, true)) { $error = true; } } } } if (!$error) { $returnValue = true; } self::remove($source, true); } else { if (file_exists($source) && file_exists($destination)) { $returnValue = rename($source, $destination); } else { if (self::copy($source, $destination, true)) { $returnValue = self::remove($source); } } } return (bool) $returnValue; } /** * Retrieve mime-types that are recognized by the TAO platform. * * @author Lionel Lecaque, * @return array An associative array of mime-types where keys are the extension related to the mime-type. Values of the array are mime-types. */ public static function getMimeTypeList() { $returnValue = [ 'txt' => 'text/plain', 'htm' => 'text/html', 'html' => 'text/html', 'xhtml' => 'application/xhtml+xml', 'php' => 'text/html', 'css' => 'text/css', 'js' => 'application/javascript', 'json' => 'application/json', 'xml' => 'text/xml', 'rdf' => 'text/xml', 'swf' => 'application/x-shockwave-flash', 'flv' => 'video/x-flv', 'csv' => 'text/csv', 'rtx' => 'text/richtext', 'rtf' => 'text/rtf', // images 'png' => 'image/png', 'jpe' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'jpg' => 'image/jpeg', 'gif' => 'image/gif', 'bmp' => 'image/bmp', 'ico' => 'image/vnd.microsoft.icon', 'tiff' => 'image/tiff', 'tif' => 'image/tiff', 'svg' => self::MIME_SVG, 'svgz' => self::MIME_SVG, // archives 'zip' => 'application/zip', 'rar' => 'application/x-rar-compressed', 'exe' => 'application/x-msdownload', 'msi' => 'application/x-msdownload', 'cab' => 'application/vnd.ms-cab-compressed', // audio/video 'mp3' => 'audio/mpeg', 'oga' => 'audio/ogg', 'ogg' => 'audio/ogg', 'aac' => 'audio/aac', 'qt' => 'video/quicktime', 'mov' => 'video/quicktime', 'mp4' => 'video/mp4',//(H.264 + AAC) for ie8, etc. 'webm' => 'video/webm',//(VP8 + Vorbis) for ie9, ff, chrome, android, opera 'ogv' => 'video/ogg',//ff, chrome, opera // adobe 'pdf' => 'application/pdf', 'psd' => 'image/vnd.adobe.photoshop', 'ai' => 'application/postscript', 'eps' => 'application/postscript', 'ps' => 'application/postscript', // ms office 'doc' => 'application/msword', 'rtf' => 'application/rtf', 'xls' => 'application/vnd.ms-excel', 'ppt' => 'application/vnd.ms-powerpoint', // open office 'odt' => 'application/vnd.oasis.opendocument.text', 'ods' => 'application/vnd.oasis.opendocument.spreadsheet', // fonts 'woff' => 'application/x-font-woff', 'eot' => 'application/vnd.ms-fontobject', 'ttf' => 'application/x-font-ttf' ]; return (array) $returnValue; } /** * Retrieve file extensions usually associated to a given mime-type. * * @author Lionel Lecaque, * @param string $mimeType A mime-type which is recognized by the platform. * @return string The extension usually associated to the mime-type. If it could not be retrieved, an empty string is returned. */ public static function getExtention($mimeType) { $returnValue = (string) ''; foreach (self::getMimeTypeList() as $key => $value) { if ($value == trim($mimeType)) { $returnValue = $key; break; } } return (string) $returnValue; } /** * Retrieve file extensions of a file * * @param string $path the path of the file we want to get the extension * @return string The extension of the parameter file */ public static function getFileExtention($path) { $ext = pathinfo($path, PATHINFO_EXTENSION); if ($ext === '') { $splitedPath = explode('.', $path); $ext = end($splitedPath); } return $ext; } /** * Get the mime-type of the file in parameter. * different methods are used regarding the configuration of the server. * * @author Lionel Lecaque, * @param string $path * @param boolean $ext If set to true, the extension of the file will be used to retrieve the mime-type. If now extension can be found, 'text/plain' is returned by the method. * @return string The associated mime-type. */ public static function getMimeType($path, $ext = false) { $mime_types = self::getMimeTypeList(); if (false == $ext) { $ext = pathinfo($path, PATHINFO_EXTENSION); if (array_key_exists($ext, $mime_types)) { $mimetype = $mime_types[$ext]; } else { $mimetype = ''; } if (!in_array($ext, ['css', 'ogg', 'mp3', 'svg', 'svgz'])) { if (file_exists($path)) { if (function_exists('finfo_open')) { $finfo = finfo_open(FILEINFO_MIME); $mimetype = finfo_file($finfo, $path); finfo_close($finfo); } elseif (function_exists('mime_content_type')) { $mimetype = mime_content_type($path); } if (!empty($mimetype)) { if (preg_match("/; charset/", $mimetype)) { $mimetypeInfos = explode(';', $mimetype); $mimetype = $mimetypeInfos[0]; } } } } } else { // find out the mime-type from the extension of the file. $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); if (array_key_exists($ext, $mime_types)) { $mimetype = $mime_types[$ext]; } } // If no mime-type found ... if (empty($mimetype)) { $mimetype = 'application/octet-stream'; } return (string) $mimetype; } /** * creates a directory in the system's temp dir. * * @author Lionel Lecaque, * @return string The path to the created folder. */ public static function createTempDir() { do { $folder = sys_get_temp_dir() . DIRECTORY_SEPARATOR . "tmp" . mt_rand() . DIRECTORY_SEPARATOR; } while (file_exists($folder)); mkdir($folder); return $folder; } /** * deletes a directory and its content. * * @author Lionel Lecaque, * @param string directory absolute path of the directory * @return boolean true if the directory and its content were deleted, false otherwise. */ public static function delTree($directory) { $files = array_diff(scandir($directory), ['.','..']); foreach ($files as $file) { $abspath = $directory . DIRECTORY_SEPARATOR . $file; if (is_dir($abspath)) { self::delTree($abspath); } else { unlink($abspath); } } return rmdir($directory); } public static function isIdentical($path1, $path2) { return self::md5_dir($path1) == self::md5_dir($path2); } public static function md5_dir($path) { if (is_file($path)) { $md5 = md5_file($path); } elseif (is_dir($path)) { $filemd5s = []; // using scandir to get files in a fixed order $files = scandir($path); sort($files); foreach ($files as $basename) { if ($basename != '.' && $basename != '..') { //$fileInfo->getFilename() $filemd5s[] = $basename . self::md5_dir(self::concat([$path, $basename])); } } $md5 = md5(implode('', $filemd5s)); } else { throw new common_Exception(__FUNCTION__ . ' called on non file or directory "' . $path . '"'); } return $md5; } /** * Create a zip of a directory or file * * @param string $src path to the files to zip * @param bool $withEmptyDir * @return string path to the zip file * @throws common_Exception if unable to create the zip */ public static function createZip($src, $withEmptyDir = false) { $zipArchive = new \ZipArchive(); $path = self::createTempDir() . 'file.zip'; if ($zipArchive->open($path, \ZipArchive::CREATE) !== true) { throw new common_Exception('Unable to create zipfile ' . $path); } self::addFilesToZip($zipArchive, $src, DIRECTORY_SEPARATOR, $withEmptyDir); $zipArchive->close(); return $path; } /** * Add files or folders (and their content) to the Zip Archive that will contain all the files to the current export session. * For instance, if you want to copy the file 'taoItems/data/i123/item.xml' as 'myitem.xml' to your archive call addFile('path_to_item_location/item.xml', 'myitem.xml'). * As a result, you will get a file entry in the final ZIP archive at '/i123/myitem.xml'. * * @param ZipArchive $zipArchive the archive to add to * @param string $src | StreamInterface The path to the source file or folder to copy into the ZIP Archive. * @param $dest * @param bool $withEmptyDir * @return integer The amount of files that were transfered from TAO to the ZIP archive within the method call. */ public static function addFilesToZip(ZipArchive $zipArchive, $src, $dest, $withEmptyDir = false) { $returnValue = null; $done = 0; if ($src instanceof \Psr\Http\Message\StreamInterface) { if ($zipArchive->addFromString(ltrim($dest, "/\\"), $src->getContents())) { $done++; } } elseif (is_resource($src)) { fseek($src, 0); $content = stream_get_contents($src); if ($zipArchive->addFromString(ltrim($dest, "/\\"), $content)) { $done++; } } elseif (is_dir($src)) { // Go deeper in folder hierarchy ! $src = rtrim($src, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; $dest = rtrim($dest, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; if ($withEmptyDir) { $zipArchive->addEmptyDir($dest); } // Recursively copy. $content = scandir($src); foreach ($content as $file) { // avoid . , .. , .svn etc ... if (!preg_match("/^\./", $file)) { $done += self::addFilesToZip($zipArchive, $src . $file, $dest . $file, $withEmptyDir); } } } else { // Simply copy the file. Beware of leading slashes if ($zipArchive->addFile($src, ltrim($dest, DIRECTORY_SEPARATOR))) { $done++; } } $returnValue = $done; return $returnValue; } /** * Unzip archive file * * @param string|File $archiveFile * @return string path to temporary directory zipfile was extracted to * * @throws \common_Exception */ public static function extractArchive($archiveFile) { if ($archiveFile instanceof File) { if (!$archiveFile->exists()) { throw new \common_Exception('Unable to open archive ' . '/' . $archiveFile->getPrefix()); } $tmpDir = static::createTempDir(); $tmpFilePath = $tmpDir . uniqid($archiveFile->getBasename(), true) . '.zip'; $tmpFile = fopen($tmpFilePath, 'w'); $originalPackage = $archiveFile->readStream(); stream_copy_to_stream($originalPackage, $tmpFile); fclose($originalPackage); fclose($tmpFile); $archiveFile = $tmpFilePath; } $archiveObj = new \ZipArchive(); $archiveHandle = $archiveObj->open($archiveFile); if (true !== $archiveHandle) { throw new \common_Exception('Unable to open archive ' . $archiveFile); } if (static::checkWhetherArchiveIsBomb($archiveObj)) { throw new \common_Exception(sprintf('Source "%s" seems to be a ZIP bomb', $archiveFile)); } $archiveDir = static::createTempDir(); if (!$archiveObj->extractTo($archiveDir)) { $archiveObj->close(); throw new \common_Exception('Unable to extract to ' . $archiveDir); } $archiveObj->close(); if (isset($tmpFilePath) && file_exists($tmpFilePath)) { unlink($tmpFilePath); } if (isset($tmpDir) && file_exists($tmpDir)) { rmdir($tmpDir); } return $archiveDir; } /** * Rename in Zip * * Rename an item in a ZIP archive. Works for files and directories. * * In case of renaming directories, the return value of this method will be the amount of files * affected by the directory renaming. * * @param ZipArchive $zipArchive An open ZipArchive object. * @param string $oldname * @param string $newname * @return int The amount of renamed entries. */ public static function renameInZip(ZipArchive $zipArchive, $oldname, $newname) { $i = 0; $renameCount = 0; while (($entryName = $zipArchive->getNameIndex($i)) || ($statIndex = $zipArchive->statIndex($i, ZipArchive::FL_UNCHANGED))) { if ($entryName) { $newEntryName = str_replace($oldname, $newname, $entryName); if ($zipArchive->renameIndex($i, $newEntryName)) { $renameCount++; } } $i++; } return $renameCount; } /** * Helps prevent decompression attacks. * Since this method checks archive file size, it needs filename property to be set, * so ZipArchive object should be already opened. * * @param \ZipArchive $archive * @param int $minCompressionRatioToBeBomb archive content size / archive size * @return bool * @throws common_Exception * * @link https://en.wikipedia.org/wiki/Zip_bomb */ public static function checkWhetherArchiveIsBomb(\ZipArchive $archive, $minCompressionRatioToBeBomb = 200) { if (!$archive->filename) { throw new common_Exception('ZIP archive should be opened before checking for a ZIP bomb'); } $contentSize = 0; for ($fileIndex = 0; $fileIndex < $archive->numFiles; $fileIndex++) { $stats = $archive->statIndex($fileIndex); $contentSize += $stats['size']; } $archiveFileSize = filesize($archive->filename); return $archiveFileSize * $minCompressionRatioToBeBomb < $contentSize; } /** * Exclude from Zip * * Exclude entries matching $pattern from a ZIP Archive. * * @param ZipArchive $zipArchive An open ZipArchive object. * @param string $pattern A PCRE pattern. * @return int The amount of excluded entries. */ public static function excludeFromZip(ZipArchive $zipArchive, $pattern) { $i = 0; $exclusionCount = 0; while (($entryName = $zipArchive->getNameIndex($i)) || ($statIndex = $zipArchive->statIndex($i, ZipArchive::FL_UNCHANGED))) { if ($entryName) { // Not previously removed index. if (preg_match($pattern, $entryName) === 1 && $zipArchive->deleteIndex($i)) { $exclusionCount++; } } $i++; } return $exclusionCount; } /** * Get All Zip Names * * Retrieve all ZIP name entries in a ZIP archive. In others words, all the paths in the * archive having an entry. * * @param ZipArchive $zipArchive An open ZipArchive object. * @return array An array of strings. */ public static function getAllZipNames(ZipArchive $zipArchive) { $i = 0; $entries = []; while (($entryName = $zipArchive->getNameIndex($i)) || ($statIndex = $zipArchive->statIndex($i, ZipArchive::FL_UNCHANGED))) { if ($entryName) { $entries[] = $entryName; } $i++; } return $entries; } /** * Gets the local path to a publicly available resource * no verification if the file should be accessible * * @param string $url * @throws common_Exception * @return string */ public static function getPathFromUrl($url) { if (substr($url, 0, strlen(ROOT_URL)) != ROOT_URL) { throw new common_Exception($url . ' does not lie within the tao instalation path'); } $subUrl = substr($url, strlen(ROOT_URL)); $parts = []; foreach (explode('/', $subUrl) as $directory) { $parts[] = urldecode($directory); } $path = ROOT_PATH . implode(DIRECTORY_SEPARATOR, $parts); if (self::securityCheck($path)) { return $path; } else { throw new common_Exception($url . ' is not secure'); } } /** * Get a safe filename for a proposed filename. * * If directory is specified it will return a filename which is * safe to not overwritte an existing file. This function is not injective. * * @param string $fileName * @param string $directory * * @return string */ public static function getSafeFileName($fileName, $directory = null) { $lastDot = strrpos($fileName, '.'); $file = $lastDot ? substr($fileName, 0, $lastDot) : $fileName; $ending = $lastDot ? substr($fileName, $lastDot + 1) : ''; $safeName = self::removeSpecChars($file); $safeEnding = empty($ending) ? '' : '.' . self::removeSpecChars($ending); if ($directory != null && file_exists($directory . $safeName . $safeEnding)) { $count = 1; while (file_exists($directory . $safeName . '_' . $count . $safeEnding)) { $count++; } $safeName = $safeName . '_' . $count; } return $safeName . $safeEnding; } /** * Remove special characters for safe filenames * * @author Dieter Raber * * @param string $string * @param string $repl * @param string $lower * * @return string */ private static function removeSpecChars($string, $repl = '-', $lower = true) { $spec_chars = [ 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Ä' => 'Ae', 'Å' => 'A','Æ' => 'A', 'Ç' => 'C', 'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E', 'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', 'Ï' => 'I', 'Ð' => 'E', 'Ñ' => 'N', 'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ö' => 'Oe', 'Ø' => 'O', 'Ù' => 'U', 'Ú' => 'U','Û' => 'U', 'Ü' => 'Ue', 'Ý' => 'Y', 'Þ' => 'T', 'ß' => 'ss', 'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'ae', 'å' => 'a', 'æ' => 'ae', 'ç' => 'c', 'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e', 'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i', 'ð' => 'e', 'ñ' => 'n', 'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'oe', 'ø' => 'o', 'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'ue', 'ý' => 'y', 'þ' => 't', 'ÿ' => 'y', ' ' => $repl, '?' => $repl, '\'' => $repl, '.' => $repl, '/' => $repl, '&' => $repl, ')' => $repl, '(' => $repl, '[' => $repl, ']' => $repl, '_' => $repl, ',' => $repl, ':' => $repl, '-' => $repl, '!' => $repl, '"' => $repl, '`' => $repl, '°' => $repl, '%' => $repl, ' ' => $repl, ' ' => $repl, '{' => $repl, '}' => $repl, '#' => $repl, '’' => $repl ]; $string = strtr($string, $spec_chars); $string = trim(preg_replace("~[^a-z0-9]+~i", $repl, $string), $repl); return $lower ? strtolower($string) : $string; } /** * Check if the directory is empty * * @param string $directory * @return boolean */ public static function isDirEmpty($directory) { $path = self::concat([$directory, '*']); return (count(glob($path, GLOB_NOSORT)) === 0 ); } }