adm_program/system/classes/FileSystemUtils.php
<?php
use Admidio\Exception;
/**
* @brief This class handles the most necessary file-system operations like:
* - Function: get normalized path, get human-readable bytes, restrict all operations to specific directories
* - Info: disk space, process owner/group info, path owner/group info, is path owner, path mode, path permissions
* - Folder: create, is empty, get content, delete content, delete folder, copy, move, chmod
* - File: delete, copy, move, chmod, read, write
*
* @copyright The Admidio Team
* @see https://www.admidio.org/
* @license https://www.gnu.org/licenses/gpl-2.0.html GNU General Public License v2.0 only
*/
final class FileSystemUtils
{
public const CONTENT_TYPE_DIRECTORY = 'directory';
public const CONTENT_TYPE_FILE = 'file';
public const CONTENT_TYPE_LINK = 'link';
public const ROOT_ID = 0;
public const ROOT_FOLDER = '/';
public const DEFAULT_MODE_DIRECTORY = 0775;
public const DEFAULT_MODE_FILE = 0664;
/**
* @var array<int,string> The allowed directories
*/
private static array $allowedDirectories = array();
/**
* @var array<string,string> Array with file extensions and the best Font Awesome icon that should be used
*/
private static array $iconFileExtension = array(
'bmp' => array('icon' => 'bi-file-earmark-image', 'mime-type' => 'image/bmp', 'viewable' => true),
'gif' => array('icon' => 'bi-file-earmark-image', 'mime-type' => 'image/gif', 'viewable' => true),
'jpg' => array('icon' => 'bi-file-earmark-image', 'mime-type' => 'image/jpeg', 'viewable' => true),
'jpeg' => array('icon' => 'bi-file-earmark-image', 'mime-type' => 'image/jpeg', 'viewable' => true),
'png' => array('icon' => 'bi-file-earmark-image', 'mime-type' => 'image/png', 'viewable' => true),
'tiff' => array('icon' => 'bi-file-earmark-image', 'mime-type' => 'image/tiff', 'viewable' => true),
'doc' => array('icon' => 'bi-file-earmark-word-fill', 'mime-type' => 'application/msword', 'viewable' => false),
'docx' => array('icon' => 'bi-file-earmark-word-fill', 'mime-type' => 'application/msword', 'viewable' => false),
'dot' => array('icon' => 'bi-file-earmark-word-fill', 'mime-type' => 'application/msword', 'viewable' => false),
'dotx' => array('icon' => 'bi-file-earmark-word-fill', 'mime-type' => 'application/msword', 'viewable' => false),
'odt' => array('icon' => 'bi-file-earmark-text-fill', 'mime-type' => 'application/vnd.oasis.opendocument.text', 'viewable' => false),
'csv' => array('icon' => 'bi-file-earmark-excel-fill', 'mime-type' => 'text/comma-separated-values', 'viewable' => false),
'xls' => array('icon' => 'bi-file-earmark-excel-fill', 'mime-type' => 'application/msexcel', 'viewable' => false),
'xlsx' => array('icon' => 'bi-file-earmark-excel-fill', 'mime-type' => 'application/msexcel', 'viewable' => false),
'xlt' => array('icon' => 'bi-file-earmark-excel-fill', 'mime-type' => 'application/msexcel', 'viewable' => false),
'xltx' => array('icon' => 'bi-file-earmark-excel-fill', 'mime-type' => 'application/msexcel', 'viewable' => false),
'ods' => array('icon' => 'bi-file-earmark-spreadsheet-fill', 'mime-type' => 'application/vnd.oasis.opendocument.spreadsheet', 'viewable' => false),
'pps' => array('icon' => 'bi-file-earmark-ppt-fill', 'mime-type' => 'application/mspowerpoint', 'viewable' => false),
'ppsx' => array('icon' => 'bi-file-earmark-ppt-fill', 'mime-type' => 'application/mspowerpoint', 'viewable' => false),
'ppt' => array('icon' => 'bi-file-earmark-ppt-fill', 'mime-type' => 'application/mspowerpoint', 'viewable' => false),
'pptx' => array('icon' => 'bi-file-earmark-ppt-fill', 'mime-type' => 'application/mspowerpoint', 'viewable' => false),
'odp' => array('icon' => 'bi-file-earmark-slides-fill', 'mime-type' => 'application/vnd.oasis.opendocument.presentation', 'viewable' => false),
'css' => array('icon' => 'bi-file-earmark-text-fill', 'mime-type' => 'text/css', 'viewable' => true),
'log' => array('icon' => 'bi-file-earmark-text-fill', 'mime-type' => 'text/plain', 'viewable' => true),
'md' => array('icon' => 'bi-file-earmark-text-fill', 'mime-type' => 'text/plain', 'viewable' => true),
'rtf' => array('icon' => 'bi-file-earmark-text-fill', 'mime-type' => 'text/rtf', 'viewable' => false),
'txt' => array('icon' => 'bi-file-earmark-text-fill', 'mime-type' => 'text/plain', 'viewable' => true),
'pdf' => array('icon' => 'bi-file-earmark-pdf-fill', 'mime-type' => 'application/pdf', 'viewable' => true),
'gz' => array('icon' => 'bi-file-earmark-zip-fill', 'mime-type' => 'application/gzip', 'viewable' => false),
'tar' => array('icon' => 'bi-file-earmark-zip-fill', 'mime-type' => 'application/x-tar', 'viewable' => false),
'zip' => array('icon' => 'bi-file-earmark-zip-fill', 'mime-type' => 'application/zip', 'viewable' => false),
'avi' => array('icon' => 'bi-file-earmark-play-fill', 'mime-type' => 'video/x-msvideo', 'viewable' => true),
'flv' => array('icon' => 'bi-file-earmark-play-fill', 'mime-type' => 'video/x-flv', 'viewable' => true),
'mov' => array('icon' => 'bi-file-earmark-play-fill', 'mime-type' => 'video/quicktime', 'viewable' => true),
'mp4' => array('icon' => 'bi-file-earmark-play-fill', 'mime-type' => 'video/mp4', 'viewable' => true),
'mpeg' => array('icon' => 'bi-file-earmark-play-fill', 'mime-type' => 'video/mpeg', 'viewable' => true),
'mpg' => array('icon' => 'bi-file-earmark-play-fill', 'mime-type' => 'video/mpeg', 'viewable' => true),
'webm' => array('icon' => 'bi-file-earmark-play-fill', 'mime-type' => 'video/webm', 'viewable' => true),
'wmv' => array('icon' => 'bi-file-earmark-play-fill', 'mime-type' => 'video/x-ms-wmv', 'viewable' => true),
'aac' => array('icon' => 'bi-file-earmark-music-fill', 'mime-type' => 'audio/aac', 'viewable' => true),
'midi' => array('icon' => 'bi-file-earmark-music-fill', 'mime-type' => 'audio/x-midi', 'viewable' => true),
'mp3' => array('icon' => 'bi-file-earmark-music-fill', 'mime-type' => 'audio/mpeg3', 'viewable' => true),
'wav' => array('icon' => 'bi-file-earmark-music-fill', 'mime-type' => 'audio/x-midi', 'viewable' => true),
'wma' => array('icon' => 'bi-file-earmark-music-fill', 'mime-type' => 'audio/x-ms-wma', 'viewable' => true)
);
/**
* Check if the file extension of the current file format is allowed for upload and the
* documents and files module.
* @param string $filename The name of the file that should be checked.
* @return bool Return true if the file extension is allowed to be used within Admidio.
*/
public static function allowedFileExtension(string $filename): bool
{
$fileExtension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (array_key_exists($fileExtension, self::$iconFileExtension)) {
return true;
}
return false;
}
/**
* Checks if file-system is UNIX
* @return bool Returns true if file-system is UNIX
*/
public static function isUnix(): bool
{
return DIRECTORY_SEPARATOR === '/';
}
/**
* Checks if file-system is UNIX and the POSIX functions are installed
* @return bool Returns true if file-system is UNIX and POSIX functions are installed
*/
public static function isUnixWithPosix(): bool
{
return self::isUnix() && function_exists('posix_getpwuid');
}
/**
* Checks if all preconditions are fulfilled
* @param string $oldDirectoryPath The source directory
* @param string $newDirectoryPath The destination directory
* @param array<string,bool> $options Operation options ([bool] createDirectoryStructure = true, [bool] overwriteContent = false)
* @return bool Returns true if content will get overwritten
* @throws RuntimeException Throws if the mkdir or opendir process fails
* @throws UnexpectedValueException Throws if source directory is not readable, destination directory is not writable or a collision is detected
*/
private static function checkDirectoryPreconditions(string $oldDirectoryPath, string $newDirectoryPath, array $options = array()): bool
{
self::checkIsInAllowedDirectories($oldDirectoryPath);
self::checkIsInAllowedDirectories($newDirectoryPath);
$defaultOptions = array('createDirectoryStructure' => true, 'overwriteContent' => false);
$options = array_merge($defaultOptions, $options);
if (!is_dir($oldDirectoryPath)) {
throw new UnexpectedValueException('Source directory "' . $oldDirectoryPath . '" does not exist!');
}
if (!is_readable($oldDirectoryPath)) {
throw new UnexpectedValueException('Source directory "' . $oldDirectoryPath . '" is not readable!');
}
if (!is_dir($newDirectoryPath)) {
if ($options['createDirectoryStructure']) {
self::createDirectoryIfNotExists($newDirectoryPath);
return false;
}
throw new UnexpectedValueException('Destination directory "' . $newDirectoryPath . '" does not exist!');
}
if (self::isUnix() && !is_executable($newDirectoryPath)) {
throw new UnexpectedValueException('Destination directory "' . $newDirectoryPath . '" is not executable!');
}
if (!is_writable($newDirectoryPath)) {
throw new UnexpectedValueException('Destination directory "' . $newDirectoryPath . '" is not writable!');
}
$oldDirectoryContentTree = self::getDirectoryContent($oldDirectoryPath, true, false);
$newDirectoryContentTree = self::getDirectoryContent($newDirectoryPath, true, false);
$collision = self::checkDirectoryContentTreeCollisions($oldDirectoryContentTree, $newDirectoryContentTree);
if (!$collision) {
return false;
}
if ($options['overwriteContent']) {
return true;
}
throw new UnexpectedValueException('Destination directory "' . $newDirectoryPath . '" has collisions!');
}
/**
* Checks if two directories have same files or directories
* @param array<string,string|array> $directoryContentTree1 Thirst directory to check
* @param array<string,string|array> $directoryContentTree2 Second directory to check
* @param bool $considerDirectoryCollisions If true, also directory collisions are checked
* @return bool Returns true if both directories has same files or directories
*/
private static function checkDirectoryContentTreeCollisions(array $directoryContentTree1, array $directoryContentTree2, bool $considerDirectoryCollisions = false): bool
{
foreach ($directoryContentTree1 as $directoryContentName => $directoryContentType1) {
if (array_key_exists($directoryContentName, $directoryContentTree2)) {
if ($considerDirectoryCollisions) {
return true;
}
$directoryContentType2 = $directoryContentTree2[$directoryContentName];
if (!is_array($directoryContentType1) || !is_array($directoryContentType2)) {
return true;
}
$collision = self::checkDirectoryContentTreeCollisions($directoryContentType1, $directoryContentType2, $considerDirectoryCollisions);
if ($collision) {
return true;
}
}
}
return false;
}
/**
* Checks the preconditions for tile copy and move
* @param string $mode The operation mode (copy or move)
* @param string $oldFilePath The source path
* @param string $newFilePath The destination path
* @param array<string,bool> $options Operation options ([bool] createDirectoryStructure = true, [bool] overwrite = false)
* @return bool Returns true if the destination path will be overwritten
* @throws RuntimeException Throws if the destination folder could not be created
* @throws UnexpectedValueException Throws if a precondition is not fulfilled
*/
private static function checkFilePreconditions(string $mode, string $oldFilePath, string $newFilePath, array $options = array()): bool
{
$defaultOptions = array('createDirectoryStructure' => true, 'overwrite' => false);
$options = array_merge($defaultOptions, $options);
self::checkIsInAllowedDirectories($oldFilePath);
self::checkIsInAllowedDirectories($newFilePath);
$oldParentDirectoryPath = dirname($oldFilePath);
if (self::isUnix() && !is_executable($oldParentDirectoryPath)) {
throw new UnexpectedValueException('Source parent directory "' . $oldParentDirectoryPath . '" is not executable!');
}
if ($mode === 'move' && !is_writable($oldParentDirectoryPath)) {
throw new UnexpectedValueException('Source parent directory "' . $oldParentDirectoryPath . '" is not writable!');
}
if (!is_file($oldFilePath)) {
throw new UnexpectedValueException('Source file "' . $oldFilePath . '" does not exist!');
}
if ($mode === 'copy' && !is_readable($oldFilePath)) {
throw new UnexpectedValueException('Source file "' . $oldFilePath . '" is not readable!');
}
$newParentDirectoryPath = dirname($newFilePath);
if (!is_dir($newParentDirectoryPath)) {
if ($options['createDirectoryStructure']) {
self::createDirectoryIfNotExists($newParentDirectoryPath);
return false;
}
throw new UnexpectedValueException('Destination parent directory "' . $newParentDirectoryPath . '" does not exist!');
}
if (self::isUnix() && !is_executable($newParentDirectoryPath)) {
throw new UnexpectedValueException('Destination parent directory "' . $newParentDirectoryPath . '" is not executable!');
}
if (!is_writable($newParentDirectoryPath)) {
throw new UnexpectedValueException('Destination parent directory "' . $newParentDirectoryPath . '" is not writable!');
}
if (!is_file($newFilePath)) {
return false;
}
if ($options['overwrite']) {
return true;
}
throw new UnexpectedValueException('Destination file "' . $newFilePath . '" already exist!');
}
/**
* Checks if a given path is in the allowed directories
* @param string $path The path to check
* @throws RuntimeException Throws if the given path is not in an allowed directory
*/
private static function checkIsInAllowedDirectories(string &$path)
{
$path = self::getNormalizedPath($path);
if (count(self::$allowedDirectories) === 0) {
return;
}
foreach (self::$allowedDirectories as $allowedDirectory) {
if (strpos($path, $allowedDirectory) === 0) {
return;
}
}
throw new RuntimeException('Path "' . $path . '" is not in allowed directory!');
}
/**
* Checks if the parent directory is executable and the path exist
* @param string $path The path to check
* @throws UnexpectedValueException Throws if path does not exist or parent directory is not executable
*/
private static function checkParentDirExecAndPathExist(string $path)
{
$parentDirectoryPath = dirname($path);
if (self::isUnix() && !is_executable($parentDirectoryPath)) {
throw new UnexpectedValueException('Parent directory "' . $parentDirectoryPath . '" is not executable!');
}
if (!file_exists($path)) {
throw new UnexpectedValueException('Path "' . $path . '" does not exist!');
}
}
/**
* Chmod a directory and optional recursive all subdirectories and files
* @param string $directoryPath The directory to chmod
* @param int $mode The mode to set, in octal notation (e.g. 0775)
* @param bool $recursive If true, subdirectories are chmod too
* @param bool $onlyDirectories If true, only directories gets chmod. If false all content gets chmod
* @throws UnexpectedValueException Throws if process is not directory owner
* @throws RuntimeException Throws if the chmod or opendir process fails
* @see https://www.php.net/manual/en/function.chmod.php
*/
public static function chmodDirectory(string $directoryPath, int $mode = self::DEFAULT_MODE_DIRECTORY, bool $recursive = false, bool $onlyDirectories = true)
{
if (!self::isUnixWithPosix()) {
throw new RuntimeException('"FileSystemUtils::chmodDirectory()" is only available on systems with POSIX support!');
}
self::checkIsInAllowedDirectories($directoryPath);
if (!is_dir($directoryPath)) {
throw new UnexpectedValueException('Directory "' . $directoryPath . '" does not exist!');
}
if (!self::hasPathOwnerRight($directoryPath)) {
throw new UnexpectedValueException('Directory "' . $directoryPath . '" owner is different to process owner!');
}
$chmodResult = chmod($directoryPath, $mode);
if (!$chmodResult) {
throw new RuntimeException('Directory "' . $directoryPath . '" mode cannot be changed!');
}
if ($recursive) {
$directoryContent = self::getDirectoryContent($directoryPath);
foreach ($directoryContent as $entryPath => $type) {
if ($type === self::CONTENT_TYPE_DIRECTORY) {
self::chmodDirectory($entryPath, $mode, $recursive, $onlyDirectories);
} elseif (!$onlyDirectories) {
self::chmodFile($entryPath, $mode);
}
}
}
}
/**
* @param string $filePath The file to chmod
* @param int $mode The mode to set in octal notation (e.g. 0664)
* @throws UnexpectedValueException Throws if the file does not exist or is not chmod-able
* @throws RuntimeException Throws if the chmod process fails
* @see https://www.php.net/manual/en/function.chmod.php
*/
public static function chmodFile(string $filePath, int $mode = self::DEFAULT_MODE_FILE)
{
if (!self::isUnixWithPosix()) {
throw new RuntimeException('"FileSystemUtils::chmodFile()" is only available on systems with POSIX support!');
}
self::checkIsInAllowedDirectories($filePath);
$parentDirectoryPath = dirname($filePath);
if (self::isUnix() && !is_executable($parentDirectoryPath)) {
throw new UnexpectedValueException('Parent directory "' . $parentDirectoryPath . '" is not executable!');
}
if (!is_file($filePath)) {
throw new UnexpectedValueException('File "' . $filePath . '" does not exist!');
}
if (!self::hasPathOwnerRight($filePath)) {
throw new UnexpectedValueException('File "' . $filePath . '" owner is different to process owner!');
}
$chmodResult = chmod($filePath, $mode);
if (!$chmodResult) {
throw new RuntimeException('File "' . $filePath . '" mode cannot be changed!');
}
}
/**
* Convert file permissions to string representation
* @param int $perms The file permissions
* @return string Returns file permissions in string representation
* @see https://www.php.net/manual/en/function.fileperms.php
*/
private static function convertPermsToString(int $perms): string
{
switch ($perms & 0xF000) {
case 0xC000: // Socket
$info = 's';
break;
case 0xA000: // Symbolic Link
$info = 'l';
break;
case 0x8000: // Regular
$info = '-'; // r
break;
case 0x6000: // Block special
$info = 'b';
break;
case 0x4000: // Directory
$info = 'd';
break;
case 0x2000: // Character special
$info = 'c';
break;
case 0x1000: // FIFO pipe
$info = 'p';
break;
default: // unknown
$info = 'u';
}
// User
$info .= (($perms & 0x0100) ? 'r' : '-');
$info .= (($perms & 0x0080) ? 'w' : '-');
$info .= (($perms & 0x0040)
? (($perms & 0x0800) ? 's' : 'x')
: (($perms & 0x0800) ? 'S' : '-'));
// Group
$info .= (($perms & 0x0020) ? 'r' : '-');
$info .= (($perms & 0x0010) ? 'w' : '-');
$info .= (($perms & 0x0008)
? (($perms & 0x0400) ? 's' : 'x')
: (($perms & 0x0400) ? 'S' : '-'));
// Other
$info .= (($perms & 0x0004) ? 'r' : '-');
$info .= (($perms & 0x0002) ? 'w' : '-');
$info .= (($perms & 0x0001)
? (($perms & 0x0200) ? 't' : 'x')
: (($perms & 0x0200) ? 'T' : '-'));
return $info;
}
/**
* Copies a directory
* @param string $oldDirectoryPath The directory to copy
* @param string $newDirectoryPath The destination directory
* @param array<string,bool> $options Operation options ([bool] createDirectoryStructure = true, [bool] overwriteContent = false)
* @return bool Returns true if content was overwritten
*@throws RuntimeException Throws if the mkdir, copy or opendir process fails
* @throws UnexpectedValueException Throws if a precondition is not fulfilled
*/
public static function copyDirectory(string $oldDirectoryPath, string $newDirectoryPath, array $options = array()): bool
{
$returnValue = self::checkDirectoryPreconditions($oldDirectoryPath, $newDirectoryPath, $options);
self::doCopyDirectory($oldDirectoryPath, $newDirectoryPath);
return $returnValue;
}
/**
* Copies a file
* @param string $oldFilePath The file to copy
* @param string $newFilePath The path where to copy to
* @param array<string,bool> $options Operation options ([bool] createDirectoryStructure = true, [bool] overwrite = false)
* @return bool Returns true if the destination path was overwritten
* @throws RuntimeException Throws if the copy process fails
* @throws UnexpectedValueException Throws if a precondition is not fulfilled
* @see https://www.php.net/manual/en/function.copy.php
*/
public static function copyFile(string $oldFilePath, string $newFilePath, array $options = array()): bool
{
$returnValue = self::checkFilePreconditions('copy', $oldFilePath, $newFilePath, $options);
$copyResult = copy($oldFilePath, $newFilePath);
if (!$copyResult) {
throw new RuntimeException('File "' . $oldFilePath . '" cannot be copied!');
}
return $returnValue;
}
/**
* Creates a directory if it already did not exist
* @param string $directoryPath The directory to create
* @param array<string,mixed> $options Operation options ([int] mode = 0775, [int] modeParents = 0775, [bool] createDirectoryStructure = true)
* @return bool Returns true if directory was successfully created or false if directory did already exist
* @throws RuntimeException Throws if the mkdir process fails
* @throws UnexpectedValueException Throws if the parent directory is not writable
* @see https://www.php.net/manual/en/function.mkdir.php
*/
public static function createDirectoryIfNotExists(string $directoryPath, array $options = array()): bool
{
self::checkIsInAllowedDirectories($directoryPath);
$defaultOptions = array('mode' => self::DEFAULT_MODE_DIRECTORY, 'modeParents' => self::DEFAULT_MODE_DIRECTORY, 'createDirectoryStructure' => true);
$options = array_merge($defaultOptions, $options);
if (is_dir($directoryPath)) {
return false;
}
$parentDirectoryPath = dirname($directoryPath);
if (!is_dir($parentDirectoryPath)) {
if ($options['createDirectoryStructure']) {
$parentOptions = $options;
$parentOptions['mode'] = $options['modeParents'];
self::createDirectoryIfNotExists($parentDirectoryPath, $parentOptions);
} else {
throw new UnexpectedValueException('Parent directory "' . $parentDirectoryPath . '" does not exist!');
}
}
if (self::isUnix() && !is_executable($parentDirectoryPath)) {
throw new UnexpectedValueException('Parent directory "' . $parentDirectoryPath . '" is not executable!');
}
if (!is_writable($parentDirectoryPath)) {
throw new UnexpectedValueException('Parent directory "' . $parentDirectoryPath . '" is not writable!');
}
$mkdirResult = mkdir($directoryPath, $options['mode']);
if (!$mkdirResult) {
throw new RuntimeException('Directory "' . $directoryPath . '" cannot be created!');
}
if (self::isUnixWithPosix()) {
if (!self::hasPathOwnerRight($directoryPath)) {
throw new UnexpectedValueException('Directory "' . $directoryPath . '" owner is different to process owner!');
}
$chmodResult = chmod($directoryPath, $options['mode']);
if (!$chmodResult) {
throw new RuntimeException('Directory "' . $directoryPath . '" mode cannot be changed!');
}
}
return true;
}
/**
* Deletes the content of a directory
* @param string $directoryPath The directory where to delete the content
* @return bool Returns true if directory content was successfully deleted or false if directory was already empty
* @throws RuntimeException Throws if unlink, rmdir or opendir process fails
* @throws UnexpectedValueException Throws if directory is not writable and readable
* @see https://www.php.net/manual/en/function.opendir.php
* @see https://www.php.net/manual/en/function.readdir.php
*/
public static function deleteDirectoryContentIfExists(string $directoryPath): bool
{
self::checkIsInAllowedDirectories($directoryPath);
if (!is_dir($directoryPath)) {
throw new UnexpectedValueException('Directory "' . $directoryPath . '" does not exist!');
}
if (self::isDirectoryEmpty($directoryPath)) {
return false;
}
if (!is_writable($directoryPath)) {
throw new UnexpectedValueException('Directory "' . $directoryPath . '" is not writable!');
}
if (!is_readable($directoryPath)) {
throw new UnexpectedValueException('Directory "' . $directoryPath . '" is not readable!');
}
$dirHandle = opendir($directoryPath);
if ($dirHandle === false) {
throw new RuntimeException('Directory "' . $directoryPath . '" cannot be opened!');
}
while (($entry = readdir($dirHandle)) !== false) {
if ($entry === '.' || $entry === '..') {
continue;
}
$directoryEntry = $directoryPath . DIRECTORY_SEPARATOR . $entry;
if (is_dir($directoryEntry)) {
self::deleteDirectoryIfExists($directoryEntry, true);
} else {
self::deleteFileIfExists($directoryEntry);
}
}
closedir($dirHandle);
return true;
}
/**
* Deletes a directory if it exists
* @param string $directoryPath The directory to delete
* @param bool $deleteWithContent If true, directory will also be deleted if directory is not empty
* @return bool Returns true if directory was successfully deleted or false if directory already did not exist
* @throws RuntimeException Throws if the rmdir or opendir process fails
* @throws UnexpectedValueException Throws if the parent directory is not writable
* @see https://www.php.net/manual/en/function.rmdir.php
*/
public static function deleteDirectoryIfExists(string $directoryPath, bool $deleteWithContent = false): bool
{
self::checkIsInAllowedDirectories($directoryPath);
if ($directoryPath === self::ROOT_FOLDER) {
throw new UnexpectedValueException('Directory "' . self::ROOT_FOLDER . '" cannot be deleted!');
}
$parentDirectoryPath = dirname($directoryPath);
if (self::isUnix() && !is_executable($parentDirectoryPath)) {
throw new UnexpectedValueException('Parent directory "' . $parentDirectoryPath . '" is not executable!');
}
if (!is_dir($directoryPath)) {
return false;
}
if (!self::isDirectoryEmpty($directoryPath)) {
if ($deleteWithContent) {
self::deleteDirectoryContentIfExists($directoryPath);
} else {
throw new UnexpectedValueException('Directory "' . $directoryPath . '" is not empty!');
}
}
if (!is_writable($parentDirectoryPath)) {
throw new UnexpectedValueException('Parent directory "' . $parentDirectoryPath . '" is not writable!');
}
$rmdirResult = rmdir($directoryPath);
if (!$rmdirResult) {
throw new RuntimeException('Directory "' . $directoryPath . '" cannot be deleted!');
}
return true;
}
/**
* Deletes a file if it exists
* @param string $filePath The file to delete
* @return bool Returns true if file was successfully deleted or false if file already did not exist
* @throws RuntimeException Throws if the delete process fails
* @throws UnexpectedValueException Throws if the file is not writable
* @see https://www.php.net/manual/en/function.unlink.php
*/
public static function deleteFileIfExists(string $filePath): bool
{
self::checkIsInAllowedDirectories($filePath);
$parentDirectoryPath = dirname($filePath);
if (self::isUnix() && !is_executable($parentDirectoryPath)) {
throw new UnexpectedValueException('Parent directory "' . $parentDirectoryPath . '" is not executable!');
}
if (!is_writable($parentDirectoryPath)) {
throw new UnexpectedValueException('Parent directory "' . $parentDirectoryPath . '" is not writable!');
}
if (!is_file($filePath)) {
return false;
}
$unlinkResult = unlink($filePath);
if (!$unlinkResult) {
throw new RuntimeException('File "' . $filePath . '" cannot be deleted!');
}
return true;
}
/**
* Execute the copy process to copy a directory
* @param string $oldDirectoryPath The directory to copy
* @param string $newDirectoryPath The destination directory
* @throws UnexpectedValueException Throws if a precondition is not fulfilled
* @throws RuntimeException Throws if the mkdir, copy or open dir process fails
*/
private static function doCopyDirectory(string $oldDirectoryPath, string $newDirectoryPath)
{
$oldDirectoryContent = self::getDirectoryContent($oldDirectoryPath, false, false);
foreach ($oldDirectoryContent as $entry => $type) {
$oldEntryPath = $oldDirectoryPath . DIRECTORY_SEPARATOR . $entry;
$newEntryPath = $newDirectoryPath . DIRECTORY_SEPARATOR . $entry;
if ($type === self::CONTENT_TYPE_DIRECTORY) {
if (!is_dir($newEntryPath)) {
self::createDirectoryIfNotExists($newEntryPath);
}
self::doCopyDirectory($oldEntryPath, $newEntryPath);
} else {
self::copyFile($oldEntryPath, $newEntryPath, array('overwrite' => true));
}
}
}
/**
* Gets the total, free and used disk space in bytes
* @param string $path Path of the filesystem
* @return array<string,float> Returns the total, free and used disk space in bytes
* @throws RuntimeException Throws if the given path is not in an allowed directory or disk-space could not be determined
* @see https://www.php.net/manual/en/function.disk-total-space.php
* @see https://www.php.net/manual/en/function.disk-free-space.php
* @example array("total" => 10737418240, "free" => 2147483648, "used" => 8589934592)
*/
public static function getDiskSpace(string $path = self::ROOT_FOLDER): array
{
self::checkIsInAllowedDirectories($path);
$total = function_exists('disk_total_space') ? disk_total_space($path) : false;
if ($total === false) {
throw new RuntimeException('Total disk-space could not be determined!');
}
$free = function_exists('disk_free_space') ? disk_free_space($path) : false;
if ($free === false) {
throw new RuntimeException('Free disk-space could not be determined!');
}
$used = $total - $free;
return array('total' => $total, 'free' => $free, 'used' => $used);
}
/**
* Get the relevant icon for the current file
* @return string Returns the name of the icon
*/
public static function getFileIcon(string $filename): string
{
$iconFile = 'bi-file-earmark-fill';
$fileExtension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (array_key_exists($fileExtension, self::$iconFileExtension)) {
$iconFile = self::$iconFileExtension[$fileExtension]['icon'];
}
return $iconFile;
}
/**
* Get the MIME type of the current file e.g. 'image/jpeg'
* @return string MIME type of the current file
*/
public static function getFileMimeType(string $filename): string
{
$mimeType = 'application/octet-stream';
$fileExtension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (array_key_exists($fileExtension, self::$iconFileExtension)) {
$mimeType = self::$iconFileExtension[$fileExtension]['mime-type'];
}
return $mimeType;
}
/**
* Get a generated filename with a timestamp and a secure random identifier
* @param string $filename The original filename
* @return string Returns the generated filename
* @throws Exception Throws if secure random identifier could not be generated
* @example "IMG_123456.JPG" => "20180131-123456_0123456789abcdef.jpg"
*/
public static function getGeneratedFilename(string $filename): string
{
$extension = pathinfo($filename, PATHINFO_EXTENSION);
$now = new DateTime();
return $now->format('Ymd-His') . '_' . SecurityUtils::getRandomString(16, '0123456789abcdefghijklmnopqrstuvwxyz') . '.' . strtolower($extension);
}
/**
* Get a from special-characters clean path-entry
* @param string $pathname The path-entry to clean
* @param string $replacer The character to replace spacial-characters with
* @return string Returns the special-characters cleaned path-entry
* @example "image-12#34+ß.jpg" => "image-12_34__.jpg"
*/
public static function getSanitizedPathEntry(string $pathname, string $replacer = '_'): string
{
return preg_replace('/[^A-Za-z0-9._-]/u', $replacer, $pathname);
}
/**
* Get a normalized/simplified path
* @param string $path The path to normalize
* @return string Returns the normalized path
* @throws UnexpectedValueException Throws if the given path is higher than root
* @see https://www.php.net/manual/en/function.realpath.php
* @example "/path/to/test/.././..//..///..///../one/two/../three/filename" => "../../one/three/filename"
*/
public static function getNormalizedPath(string $path): string
{
$path = str_replace('\\', '/', $path); // Replace back-slashes with forward-slashes
$path = preg_replace('/\/+/', '/', $path); // Combine multiple slashes into a single slash
$segments = explode('/', $path);
$parts = array();
foreach ($segments as $segment) {
if ($segment === '.') {
// Actual directory => ignore
continue;
}
$test = array_pop($parts);
if ($test === null) {
// No path added => add first path
$parts[] = $segment;
} elseif ($segment === '..') {
if ($test === '..') {
// Change to grand-parent directory => add two times ".."
$parts[] = $test;
$parts[] = $segment;
} elseif ($test === '') {
// File-system root => higher as root is not possible/valid => throw Exception
throw new UnexpectedValueException('Path "' . $path . '" is higher than root!');
}
// else
// {
// // Change to parent directory => ignore
// }
} else {
// New sub-path => add parent path and new path
$parts[] = $test;
$parts[] = $segment;
}
}
return implode(DIRECTORY_SEPARATOR, $parts);
}
/**
* Gets human readable bytes with unit
* @param int $bytes The bytes
* @param bool $si Use SI or binary unit. Set true for SI units
* @return string Returns human readable bytes with unit.
* @example "[value] [unit]" (e.g: 34.5 MiB)
*/
public static function getHumanReadableBytes(int $bytes, bool $si = false): string
{
$divider = 1024;
$units = array('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'YiB');
if ($si) {
$divider = 1000;
$units = array('B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'YB');
}
$iteration = 0;
while ($bytes >= $divider) {
++$iteration;
$bytes /= $divider;
}
$unit = $units[$iteration];
return round($bytes, 3 - (int) floor(log10($bytes))) . ' ' . $unit;
}
/**
* Gets info about the php-process owner
* @throws RuntimeException Throws if system does not support POSIX
* @return array<string,string|int> Returns info about the php-process owner
* @see https://www.php.net/manual/en/function.posix-geteuid.php
* @see https://www.php.net/manual/en/function.posix-getpwuid.php
* @example
* array(
* "name" => "max", "passwd" => "x", "uid" => 1000, "gid" => 1000,
* "gecos" => "max,,,", "dir" => "/home/max", "shell" => "/bin/bash"
* )
*/
public static function getProcessOwnerInfo(): array
{
if (!self::isUnix()) {
throw new RuntimeException('"FileSystemUtils::getProcessOwnerInfo()" is only available on systems with POSIX support!');
}
return posix_getpwuid(posix_geteuid());
}
/**
* Gets info about the php-process group
* @throws RuntimeException Throws if system does not support POSIX
* @return array<string,string|int|array> Returns info about the php-process group
* @see https://www.php.net/manual/en/function.posix-getegid.php
* @see https://www.php.net/manual/en/function.posix-getgrgid.php
* @example array("name" => "max", "passwd" => "x", "members" => array(), "gid" => 1000)
*/
public static function getProcessGroupInfo(): array
{
if (!self::isUnix()) {
throw new RuntimeException('"FileSystemUtils::getProcessGroupInfo()" is only available on systems with POSIX support!');
}
return posix_getgrgid(posix_getegid());
}
/**
* Gets info about the path owner
* @param string $path The path from which to get the information
* @return array<string,string|int> Returns info about the path owner
* @throws RuntimeException Throws if the fileowner determination fails or system does not support POSIX
* @throws UnexpectedValueException Throws if path does not exist
* @see https://www.php.net/manual/en/function.fileowner.php
*/
public static function getPathOwnerInfo(string $path): array
{
if (!self::isUnixWithPosix()) {
throw new RuntimeException('"FileSystemUtils::getPathOwnerInfo()" is only available on systems with POSIX support!');
}
self::checkIsInAllowedDirectories($path);
self::checkParentDirExecAndPathExist($path);
$fileOwnerResult = fileowner($path);
if ($fileOwnerResult === false) {
throw new RuntimeException('File "' . $path . '" owner cannot be determined!');
}
return posix_getpwuid($fileOwnerResult);
}
/**
* Gets info about the path group
* @param string $path The path from which to get the information
* @return array<string,string|int|array> Returns info about the path group
* @throws RuntimeException Throws if the groupowner determination fails or system does not support POSIX
* @throws UnexpectedValueException Throws if path does not exist
* @see https://www.php.net/manual/en/function.filegroup.php
*/
public static function getPathGroupInfo(string $path): array
{
if (!self::isUnix()) {
throw new RuntimeException('"FileSystemUtils::getPathGroupInfo()" is only available on systems with POSIX support!');
}
self::checkIsInAllowedDirectories($path);
self::checkParentDirExecAndPathExist($path);
$fileGroupResult = filegroup($path);
if ($fileGroupResult === false) {
throw new RuntimeException('File "' . $path . '" group cannot be determined!');
}
return posix_getgrgid($fileGroupResult);
}
/**
* Checks if the php-process is the path owner
* @param string $path The path from which to get the information
* @return bool Returns true if php-process is the path owner
* @throws RuntimeException Throws if the fileowner determination fails or system does not support POSIX
* @throws UnexpectedValueException Throws if path does not exist
* @see https://www.php.net/manual/en/function.posix-geteuid.php
* @see https://www.php.net/manual/en/function.posix-getpwuid.php
* @see https://www.php.net/manual/en/function.fileowner.php
*/
public static function hasPathOwnerRight(string $path): bool
{
if (!self::isUnixWithPosix()) {
throw new RuntimeException('"FileSystemUtils::hasPathOwnerRight()" is only available on systems with POSIX support!');
}
self::checkIsInAllowedDirectories($path);
$processOwnerInfo = self::getProcessOwnerInfo();
$pathOwnerInfo = self::getPathOwnerInfo($path);
return $processOwnerInfo['uid'] === self::ROOT_ID || $processOwnerInfo['uid'] === $pathOwnerInfo['uid'];
}
/**
* Gets the mode permissions of a path
* @param string $path The path from which to get the information
* @param bool $octal Set true to get the octal instead of the string mode representation
* @return string Returns the mode permissions of a path in octal or string representation
* @throws RuntimeException Throws if the permissions determination fails
* @throws UnexpectedValueException Throws if path does not exist
* @see https://www.php.net/manual/en/function.fileperms.php
* @example "drwxrwxr-x" or "0775"
*/
public static function getPathMode(string $path, bool $octal = false): string
{
self::checkIsInAllowedDirectories($path);
self::checkParentDirExecAndPathExist($path);
$perms = fileperms($path);
if ($perms === false) {
throw new RuntimeException('File "' . $path . '" permissions cannot be read!');
}
if ($octal) {
return substr(sprintf('%o', $perms), -4);
}
return self::convertPermsToString($perms);
}
/**
* Gets owner, group and mode info from a path
* @param string $path The path from which to get the information
* @return array<string,string> Returns owner, group and mode info from a path
* @throws RuntimeException Throws if an info determination fails
* @throws UnexpectedValueException Throws if path does not exist
* @see https://www.php.net/manual/en/function.fileowner.php
* @see https://www.php.net/manual/en/function.filegroup.php
* @see https://www.php.net/manual/en/function.fileperms.php
* @example array("owner" => "www-data", "group" => "www", "mode" => "drwxrwxr-x")
*/
public static function getPathPermissions(string $path): array
{
self::checkIsInAllowedDirectories($path);
self::checkParentDirExecAndPathExist($path);
if (self::isUnixWithPosix()) {
$ownerInfo = self::getPathOwnerInfo($path);
$groupInfo = self::getPathGroupInfo($path);
return array(
'owner' => $ownerInfo['name'],
'group' => $groupInfo['name'],
'mode' => self::getPathMode($path)
);
}
return array(
'owner' => null,
'group' => null,
'mode' => self::getPathMode($path)
);
}
/**
* Gets the content of a directory and optional recursive from all subdirectories
* @param string $directoryPath The directory from which to get the content
* @param bool $recursive If true, also all subdirectories are scanned
* @param bool $fullPath Set true to get the full paths instead of the content entry names
* @param array<int,string> $includedContentTypes A list with all content types that should get returned (directories, files, links)
* @return array<string,string|array> The content of the directory (and all the subdirectories)
* @throws RuntimeException Throws if the open dir process fails
* @throws UnexpectedValueException Throws if directory is not readable
* @see https://www.php.net/manual/en/function.opendir.php
* @see https://www.php.net/manual/en/function.readdir.php
*/
public static function getDirectoryContent(string $directoryPath, bool $recursive = false, bool $fullPath = true, array $includedContentTypes = array(self::CONTENT_TYPE_DIRECTORY, self::CONTENT_TYPE_FILE, self::CONTENT_TYPE_LINK)): array
{
self::checkIsInAllowedDirectories($directoryPath);
if (!is_dir($directoryPath)) {
throw new UnexpectedValueException('Directory "' . $directoryPath . '" does not exist!');
}
if (!is_readable($directoryPath)) {
throw new UnexpectedValueException('Directory "' . $directoryPath . '" is not readable!');
}
$dirHandle = opendir($directoryPath);
if ($dirHandle === false) {
throw new RuntimeException('Directory "' . $directoryPath . '" cannot be opened!');
}
$directoryContent = array();
while (($entry = readdir($dirHandle)) !== false) {
if ($entry === '.' || $entry === '..' || strpos($entry, '.') === 0) {
continue;
}
$directoryEntry = $directoryPath . DIRECTORY_SEPARATOR . $entry;
$entryValue = $fullPath ? $directoryEntry : (string) $entry;
if (is_dir($directoryEntry)) {
if ($recursive) {
$directoryContent[$entryValue] = self::getDirectoryContent($directoryEntry, $recursive, $fullPath, $includedContentTypes);
} elseif (in_array(self::CONTENT_TYPE_DIRECTORY, $includedContentTypes, true)) {
$directoryContent[$entryValue] = self::CONTENT_TYPE_DIRECTORY;
}
} elseif (is_file($directoryEntry) && in_array(self::CONTENT_TYPE_FILE, $includedContentTypes, true)) {
$directoryContent[$entryValue] = self::CONTENT_TYPE_FILE;
} elseif (is_link($directoryEntry) && in_array(self::CONTENT_TYPE_LINK, $includedContentTypes, true)) {
$directoryContent[$entryValue] = self::CONTENT_TYPE_LINK;
}
}
closedir($dirHandle);
return $directoryContent;
}
/**
* Checks if a directory is empty
* @param string $directoryPath The directory to check if is empty
* @return bool Returns true if the directory is empty
* @throws RuntimeException Throws if the opendir process fails
* @throws UnexpectedValueException Throws if the directory is not readable
* @see https://www.php.net/manual/en/function.opendir.php
* @see https://www.php.net/manual/en/function.readdir.php
*/
public static function isDirectoryEmpty(string $directoryPath): bool
{
self::checkIsInAllowedDirectories($directoryPath);
if (!is_dir($directoryPath)) {
throw new UnexpectedValueException('Directory "' . $directoryPath . '" does not exist!');
}
if (!is_readable($directoryPath)) {
throw new UnexpectedValueException('Directory "' . $directoryPath . '" is not readable!');
}
$dirHandle = opendir($directoryPath);
if ($dirHandle === false) {
throw new RuntimeException('Directory "' . $directoryPath . '" cannot be opened!');
}
while (($entry = readdir($dirHandle)) !== false) {
if ($entry !== '.' && $entry !== '..') {
closedir($dirHandle);
return false;
}
}
closedir($dirHandle);
return true;
}
/**
* Check if the current file format could be viewed within a browser.
* @return bool Return true if the file could be viewed in the browser otherwise false.
*/
public static function isViewableFileInBrowser($filename)
{
$returnCode = false;
$fileExtension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (array_key_exists($fileExtension, self::$iconFileExtension)) {
$returnCode = self::$iconFileExtension[$fileExtension]['viewable'];
}
return $returnCode;
}
/**
* Moves a directory
* @param string $oldDirectoryPath The directory to move
* @param string $newDirectoryPath The destination directory
* @param array<string,bool> $options Operation options ([bool] createDirectoryStructure = true, [bool] overwriteContent = false)
* @return bool Returns true if content was overwritten
* @throws RuntimeException Throws if the mkdir, copy, rmdir, unlink or open dir process fails
* @throws UnexpectedValueException Throws if a precondition is not fulfilled
*/
public static function moveDirectory(string $oldDirectoryPath, string $newDirectoryPath, array $options = array()): bool
{
$returnValue = self::checkDirectoryPreconditions($oldDirectoryPath, $newDirectoryPath, $options);
self::doCopyDirectory($oldDirectoryPath, $newDirectoryPath);
self::deleteDirectoryIfExists($oldDirectoryPath, true);
return $returnValue;
}
/**
* Moves a file
* @param string $oldFilePath The path and file to move
* @param string $newFilePath The path and file where to move to
* @param array<string,bool> $options Operation options ([bool] createDirectoryStructure = true, [bool] overwrite = false)
* @return bool Returns true if the destination path was overwritten
* @throws RuntimeException Throws if the move process fails
* @throws UnexpectedValueException Throws if a precondition is not fulfilled
* @see https://www.php.net/manual/en/function.rename.php
*/
public static function moveFile(string $oldFilePath, string $newFilePath, array $options = array()): bool
{
$returnValue = self::checkFilePreconditions('move', $oldFilePath, $newFilePath, $options);
$renameResult = rename($oldFilePath, $newFilePath);
if (!$renameResult) {
throw new RuntimeException('File "' . $oldFilePath . '" cannot be moved!');
}
return $returnValue;
}
/**
* Method will read the content of the file that is set through the parameter and return the
* file content. It will check if the file exists and if it's readable for the PHP user.
* @param string $filePath The file to read
* @return string Returns the file content
* @throws RuntimeException Throws if the read process fails
* @throws UnexpectedValueException Throws if the file does not exist or is not readable
* @see https://www.php.net/manual/en/function.file-get-contents.php
*/
public static function readFile(string $filePath): string
{
self::checkIsInAllowedDirectories($filePath);
if (!is_file($filePath)) {
throw new UnexpectedValueException('File "' . $filePath . '" does not exist!');
}
if (!is_readable($filePath)) {
throw new UnexpectedValueException('File "' . $filePath . '" is not readable!');
}
$data = file_get_contents($filePath);
if ($data === false) {
throw new RuntimeException('File "' . $filePath . '" cannot be read!');
}
return $data;
}
/**
* Remove anything which isn't a word, whitespace, number
* or any of the following characters: "-_~:;<>|[]()."
* @params string $filename The filename where the invalid characters should be removed
* @return string Returns the filename with the removed invalid characters
*/
public static function removeInvalidCharsInFilename(string $filename): string
{
// remove NULL value from filename
$filename = str_replace(chr(0), '', $filename);
//$filename = preg_replace("/([^\w\s\d\-_~:;<>|\[\]\(\).])/u", '', $filename);
$filename = preg_replace("/<>:\?\/\*\"'/", '-', $filename);
// Remove any runs of periods
$filename = preg_replace("/([.]{2,})/u", '', $filename);
return $filename;
}
/**
* Restrict all operations of this class to specific directories
* @param array<int,string> $directoryPaths The allowed directories
* @throws UnexpectedValueException Throws if a given directory does not exist
*/
public static function setAllowedDirectories(array $directoryPaths = array())
{
foreach ($directoryPaths as &$directoryPath) {
$directoryPath = self::getNormalizedPath($directoryPath);
if (!is_dir($directoryPath)) {
throw new UnexpectedValueException('Directory "' . $directoryPath . '" does not exist!');
}
}
unset($directoryPath);
self::$allowedDirectories = $directoryPaths;
}
/**
* Write some data into a file
* @param string $filePath The file to write
* @param string $data The data to write
* @param bool $append If true the data gets appended instead of overwriting the content
* @return int Returns the written bytes
* @throws RuntimeException Throws if the write process fails
* @throws UnexpectedValueException Throws if the file or parent directory is not writable
* @see https://www.php.net/manual/en/function.file-put-contents.php
*/
public static function writeFile(string $filePath, string $data, bool $append = false): int
{
self::checkIsInAllowedDirectories($filePath);
$parentDirectoryPath = dirname($filePath);
if (self::isUnix() && !is_executable($parentDirectoryPath)) {
throw new UnexpectedValueException('Parent directory "' . $parentDirectoryPath . '" is not executable!');
}
if (is_file($filePath)) {
if (!is_writable($filePath)) {
throw new UnexpectedValueException('File "' . $filePath . '" is not writable!');
}
} else {
if (!is_writable($parentDirectoryPath)) {
throw new UnexpectedValueException('Parent directory "' . $parentDirectoryPath . '" is not writable!');
}
}
$flags = 0;
if ($append) {
$flags = FILE_APPEND;
}
$bytes = file_put_contents($filePath, $data, $flags);
if ($bytes === false) {
throw new RuntimeException('File "' . $filePath . '" cannot be written!');
}
return $bytes;
}
}