<?php /** * @package Regular Labs Library * @version 25.7.12430 * * @author Peter van Westen <[email protected]> * @link https://regularlabs.com * @copyright Copyright © 2025 Regular Labs All Rights Reserved * @license GNU General Public License version 2 or later */ namespace RegularLabs\Library; defined('_JEXEC') or die; use Exception; use Joomla\CMS\Client\ClientHelper as JClientHelper; use Joomla\CMS\Client\FtpClient as JFtpClient; use Joomla\CMS\Factory as JFactory; use Joomla\CMS\Language\Text as JText; use Joomla\CMS\Log\Log as JLog; use Joomla\CMS\Uri\Uri as JUri; use Joomla\Filesystem\Folder as JFolder; use Joomla\Filesystem\Path as JPath; use function set_time_limit; class File { static $is_external = []; /** * some/url/to/a/file.ext * > some/url/to/a/file_suffix.ext */ public static function addSuffix(string $url, string $suffix): string { $url = \RegularLabs\Library\StringHelper::normalize($url); $info = pathinfo($url); return ($info['dirname'] ?? '') . '/' . ($info['filename'] ?? '') . $suffix . '.' . ($info['extension'] ?? ''); } /** * Delete a file or array of files */ public static function delete(string|array $file, bool $show_messages = \false, int $min_age_in_minutes = 0): bool { $FTPOptions = JClientHelper::getCredentials('ftp'); $pathObject = new JPath(); $files = is_array($file) ? $file : [$file]; if ($FTPOptions['enabled'] == 1) { // Connect the FTP client $ftp = JFtpClient::getInstance($FTPOptions['host'], $FTPOptions['port'], [], $FTPOptions['user'], $FTPOptions['pass']); } foreach ($files as $file) { $file = $pathObject->clean($file); if (!is_file($file)) { continue; } if ($min_age_in_minutes && floor((time() - filemtime($file)) / 60) < $min_age_in_minutes) { continue; } // Try making the file writable first. If it's read-only, it can't be deleted // on Windows, even if the parent folder is writable @chmod($file, 0777); if ($FTPOptions['enabled'] == 1) { $file = $pathObject->clean(str_replace(JPATH_ROOT, $FTPOptions['root'], $file), '/'); if (!$ftp->delete($file)) { // FTP connector throws an error return \false; } } // Try the unlink twice in case something was blocking it on first try if (!@unlink($file) && !@unlink($file)) { $show_messages && JLog::add(JText::sprintf('JLIB_FILESYSTEM_DELETE_FAILED', basename($file)), JLog::WARNING, 'jerror'); return \false; } } return \true; } /** * Delete a folder. */ public static function deleteFolder(string $path, bool $show_messages = \false, int $min_age_in_minutes = 0): bool { // check if set_time_limit is available if (function_exists('set_time_limit') && ini_get('max_execution_time') != 0) { @set_time_limit(ini_get('max_execution_time')); } $pathObject = new JPath(); if (!$path) { $show_messages && JLog::add(JText::_('JLIB_FILESYSTEM_ERROR_DELETE_BASE_DIRECTORY'), JLog::WARNING, 'jerror'); return \false; } // Check to make sure the path valid and clean $path = $pathObject->clean($path); if (!is_dir($path)) { $show_messages && JLog::add(JText::sprintf('JLIB_FILESYSTEM_ERROR_PATH_IS_NOT_A_FOLDER', $path), JLog::WARNING, 'jerror'); return \false; } if (!self::deleteNestedFiles($path, $show_messages, $min_age_in_minutes)) { // self::deleteNestedFiles throws an error return \false; } if (!self::deleteNestedFolders($path, $show_messages, $min_age_in_minutes)) { // self::deleteNestedFolders throws an error return \false; } // Skip if folder is not empty yet if (!self::isEmpty($path)) { return \true; } if (@rmdir($path) && @rmdir($path)) { return \true; } // Try deleting the folder through FTP self::deleteFolderThroughFTP($path, $pathObject); if (@rmdir($path) && @rmdir($path)) { return \true; } $show_messages && JLog::add(JText::sprintf('JLIB_FILESYSTEM_ERROR_FOLDER_DELETE', $path), JLog::WARNING, 'jerror'); return \false; } /** * some/url/to/a/file.ext * > file.ext */ public static function getBaseName(string $url, bool $lowercase = \false): string { $url = \RegularLabs\Library\StringHelper::normalize($url); $basename = ltrim(basename($url), '/'); $parts = explode('?', $basename); $basename = $parts[0]; if ($lowercase) { $basename = strtolower($basename); } return $basename; } /** * some/url/to/a/file.ext * > some/url/to/a */ public static function getDirName(string $url): string { $url = \RegularLabs\Library\StringHelper::normalize($url); $url = strtok($url, '?'); $url = strtok($url, '#'); return rtrim(dirname($url), '/'); } /** * some/url/to/a/file.ext * > ext */ public static function getExtension(string $url): string { $info = pathinfo($url); if (!isset($info['extension'])) { return ''; } $ext = explode('?', $info['extension']); return strtolower($ext[0]); } /** * some/url/to/a/file.ext * > file */ public static function getFileName(string $url, bool $lowercase = \false): string { $url = \RegularLabs\Library\StringHelper::normalize($url); $info = @pathinfo($url); $filename = $info['filename'] ?? $url; if ($lowercase) { $filename = strtolower($filename); } return $filename; } public static function getFileTypes(string $type = 'images'): array { return match ($type) { 'image', 'images' => ['bmp', 'flif', 'gif', 'jpe', 'jpeg', 'jpg', 'png', 'tiff', 'eps', 'webp'], 'audio' => ['aif', 'aiff', 'mp3', 'wav'], 'video', 'videos' => ['3g2', '3gp', 'avi', 'divx', 'f4v', 'flv', 'm4v', 'mov', 'mp4', 'mpe', 'mpeg', 'mpg', 'ogv', 'swf', 'webm', 'wmv'], 'document', 'documents' => ['doc', 'docm', 'docx', 'dotm', 'dotx', 'odb', 'odc', 'odf', 'odg', 'odi', 'odm', 'odp', 'ods', 'odt', 'onepkg', 'onetmp', 'onetoc', 'onetoc2', 'otg', 'oth', 'otp', 'ots', 'ott', 'oxt', 'pdf', 'potm', 'potx', 'ppam', 'pps', 'ppsm', 'ppsx', 'ppt', 'pptm', 'pptx', 'rtf', 'sldm', 'sldx', 'thmx', 'xla', 'xlam', 'xlc', 'xld', 'xll', 'xlm', 'xls', 'xlsb', 'xlsm', 'xlsx', 'xlt', 'xltm', 'xltx', 'xlw'], 'other', 'others' => ['css', 'csv', 'js', 'json', 'tar', 'txt', 'xml', 'zip'], default => [...self::getFileTypes('images'), ...self::getFileTypes('audio'), ...self::getFileTypes('videos'), ...self::getFileTypes('documents'), ...self::getFileTypes('other')], }; } /** * Find a matching media file in the different possible extension media folders for given type */ public static function getMediaFile(string $type, string $file): bool|string { // If http is present in filename if (str_starts_with($file, 'http') || str_starts_with($file, '//')) { return $file; } $files = []; // Detect debug mode if (\RegularLabs\Library\Document::isDebug()) { $files[] = str_replace(['.min.', '-min.'], '.', $file); } $files[] = $file; /** * Loop on 1 or 2 files and break on first find. * Add the content of the MD5SUM file located in the same folder to url to ensure cache browser refresh * This MD5SUM file must represent the signature of the folder content */ foreach ($files as $check_file) { $file_found = self::findMediaFileByFile($check_file, $type); if (!$file_found) { continue; } return $file_found; } return \false; } public static function isDocument(string $url): bool { return self::isMedia($url, self::getFileTypes('documents')); } public static function isExternal(string $url): bool { if (isset(static::$is_external[$url])) { return static::$is_external[$url]; } $uri = parse_url($url); if (empty($uri['host'])) { static::$is_external[$url] = \false; return static::$is_external[$url]; } // give preference to SERVER_NAME, because this includes subdomains $hostname = $_SERVER['SERVER_NAME'] ?: $_SERVER['HTTP_HOST']; static::$is_external[$url] = !(strcasecmp($hostname, $uri['host']) === 0); return static::$is_external[$url]; } public static function isExternalVideo(string $url): bool { return str_contains($url, 'youtu.be') || str_contains($url, 'youtube.com') || str_contains($url, 'vimeo.com'); } public static function isImage($url) { return self::isMedia($url, self::getFileTypes('images')); } public static function isInternal(string $url): bool { return !self::isExternal($url); } public static function isMedia(string $url, array|string $filetypes = []): bool { $filetype = self::getExtension($url); if (empty($filetype)) { return \false; } if (!is_array($filetypes)) { $filetypes = [$filetypes]; } if (count($filetypes) == 1 && str_contains($filetypes[0], ',')) { $filetypes = \RegularLabs\Library\ArrayHelper::toArray($filetypes[0]); } $filetypes = $filetypes ?? null ?: self::getFileTypes(); return in_array($filetype, $filetypes); } public static function isVideo(string $url): bool { return self::isMedia($url, self::getFileTypes('videos')); } public static function trimFolder(string $folder): string { return trim(str_replace(['\\', '//'], '/', $folder), '/'); } private static function deleteFolderThroughFTP(string $path, JPath $pathObject): bool { $FTPOptions = JClientHelper::getCredentials('ftp'); if (($FTPOptions['enabled'] ?? 0) != 1) { return \true; } try { // Connect the FTP client $ftp = JFtpClient::getInstance($FTPOptions['host'], $FTPOptions['port'], [], $FTPOptions['user'], $FTPOptions['pass']); // Translate path and delete $path = $pathObject->clean(str_replace(JPATH_ROOT, $FTPOptions['root'], $path), '/'); // FTP connector throws an error return $ftp->delete($path); } catch (Exception $e) { return \false; } } private static function deleteNestedFiles(string $path, bool $show_messages = \false, int $min_age_in_minutes = 0): bool { try { // Remove all the files in folder if they exist; disable all filtering $files = JFolder::files($path, '.', \false, \true, [], []); if (empty($files)) { return \true; } if (self::delete($files, $show_messages, $min_age_in_minutes) !== \true) { return \false; } return \true; } catch (Exception $e) { return \false; } } private static function deleteNestedFolders(string $path, bool $show_messages = \false, int $min_age_in_minutes = 0): bool { try { // Remove sub-folders of folder; disable all filtering $folders = JFolder::folders($path, '.', \false, \true, [], []); if (empty($folders)) { return \true; } foreach ($folders as $folder) { if (is_link($folder)) { // Don't descend into linked directories, just delete the link. if (self::delete($folder, $show_messages, $min_age_in_minutes) !== \true) { return \false; } continue; } if (!self::deleteFolder($folder, $show_messages, $min_age_in_minutes)) { return \false; } } return \true; } catch (Exception $e) { return \false; } } /** * Find a matching media file in the different possible extension media folders for given type */ private static function findMediaFileByFile(string $file, string $type): string|false { $template = JFactory::getApplication()->getTemplate(); // If the file is in the template folder $file_found = self::getFileUrl('/templates/' . $template . '/' . $type . '/' . $file); if ($file_found) { return $file_found; } // Try to deal with system files in the media folder if (!str_contains($file, '/')) { $file_found = self::getFileUrl('/media/system/' . $type . '/' . $file); if (!$file_found) { return \false; } return $file_found; } $paths = []; // If the file contains any /: it can be in a media extension subfolder // Divide the file extracting the extension as the first part before / [$extension, $file] = explode('/', $file, 2); $paths[] = '/media/' . $extension . '/' . $type; $paths[] = '/templates/' . $template . '/' . $type . '/system'; $paths[] = '/media/system/' . $type; $paths[] = ''; foreach ($paths as $path) { $file_found = self::getFileUrl($path . '/' . $file); if (!$file_found) { continue; } return $file_found; } return \false; } /** * Get the url for the file */ private static function getFileUrl(string $path): string|false { if (!file_exists(JPATH_ROOT . $path)) { return \false; } return JUri::root(\true) . $path; } private static function isEmpty(string $path): bool { return empty(JFolder::files($path, '.', \false, \true, [], [])) && empty(JFolder::folders($path, '.', \false, \true, [], [])); } }