storageFolder = self::validatePath(Config::get('cms.storage.media.folder', 'media'), true); $this->storagePath = rtrim(Config::get('cms.storage.media.path', '/storage/app/media'), '/'); if (!starts_with($this->storagePath, ['//', 'http://', 'https://'])) { $this->storagePath = Request::getBasePath() . $this->storagePath; } $this->ignoreNames = Config::get('cms.storage.media.ignore', FileDefinitions::get('ignoreFiles')); $this->ignorePatterns = Config::get('cms.storage.media.ignorePatterns', ['^\..*']); $this->storageFolderNameLength = strlen($this->storageFolder); } /** * Returns a list of folders and files in a Library folder. * * @param string $folder Specifies the folder path relative the the Library root. * @param mixed $sortBy Determines the sorting preference. * Supported values are 'title', 'size', 'lastModified' (see SORT_BY_XXX class constants), FALSE (to disable sorting), or an associative array with a 'by' key and a 'direction' key: ['by' => SORT_BY_XXX, 'direction' => SORT_DIRECTION_XXX]. * @param string $filter Determines the document type filtering preference. * Supported values are 'image', 'video', 'audio', 'document' (see FILE_TYPE_XXX constants of MediaLibraryItem class). * @param boolean $ignoreFolders Determines whether folders should be suppressed in the result list. * @return array Returns an array of MediaLibraryItem objects. */ public function listFolderContents($folder = '/', $sortBy = 'title', $filter = null, $ignoreFolders = false) { $folder = self::validatePath($folder); $fullFolderPath = $this->getMediaPath($folder); /* * Try to load the contents from cache */ $cached = Cache::get(self::CACHE_KEY, false); $cached = $cached ? @unserialize(@base64_decode($cached)) : []; if (!is_array($cached)) { $cached = []; } if (array_key_exists($fullFolderPath, $cached)) { $folderContents = $cached[$fullFolderPath]; } else { $folderContents = $this->scanFolderContents($fullFolderPath); $cached[$fullFolderPath] = $folderContents; Cache::put( self::CACHE_KEY, base64_encode(serialize($cached)), Config::get('cms.storage.media.ttl', 10) ); } /* * Sort the result and combine the file and folder lists */ if ($sortBy !== false) { $this->sortItemList($folderContents['files'], $sortBy); $this->sortItemList($folderContents['folders'], $sortBy); } $this->filterItemList($folderContents['files'], $filter); if (!$ignoreFolders) { $folderContents = array_merge($folderContents['folders'], $folderContents['files']); } else { $folderContents = $folderContents['files']; } return $folderContents; } /** * Finds files in the Library. * @param string $searchTerm Specifies the search term. * @param mixed $sortBy Determines the sorting preference. * Supported values are 'title', 'size', 'lastModified' (see SORT_BY_XXX class constants), FALSE (to disable sorting), or an associative array with a 'by' key and a 'direction' key: ['by' => SORT_BY_XXX, 'direction' => SORT_DIRECTION_XXX]. * @param string $filter Determines the document type filtering preference. * Supported values are 'image', 'video', 'audio', 'document' (see FILE_TYPE_XXX constants of MediaLibraryItem class). * @return array Returns an array of MediaLibraryItem objects. */ public function findFiles($searchTerm, $sortBy = 'title', $filter = null) { $words = explode(' ', Str::lower($searchTerm)); $result = []; $findInFolder = function ($folder) use (&$findInFolder, $words, &$result, $sortBy, $filter) { $folderContents = $this->listFolderContents($folder, $sortBy, $filter); foreach ($folderContents as $item) { if ($item->type == MediaLibraryItem::TYPE_FOLDER) { $findInFolder($item->path); } elseif ($this->pathMatchesSearch($item->path, $words)) { $result[] = $item; } } }; $findInFolder('/'); /* * Sort the result */ if ($sortBy !== false) { $this->sortItemList($result, $sortBy); } return $result; } /** * Deletes a file from the Library. * @param array $paths A list of file paths relative to the Library root to delete. */ public function deleteFiles($paths) { $fullPaths = []; foreach ($paths as $path) { $path = self::validatePath($path); $fullPaths[] = $this->getMediaPath($path); } return $this->getStorageDisk()->delete($fullPaths); } /** * Deletes a folder from the Library. * @param string $path Specifies the folder path relative to the Library root. */ public function deleteFolder($path) { $path = self::validatePath($path); $fullPaths = $this->getMediaPath($path); return $this->getStorageDisk()->deleteDirectory($fullPaths); } /** * Determines if a file with the specified path exists in the library. * @param string $path Specifies the file path relative the the Library root. * @return boolean Returns TRUE if the file exists. */ public function exists($path) { $path = self::validatePath($path); $fullPath = $this->getMediaPath($path); return $this->getStorageDisk()->exists($fullPath); } /** * Determines if a folder with the specified path exists in the library. * @param string $path Specifies the folder path relative the the Library root. * @return boolean Returns TRUE if the folder exists. */ public function folderExists($path) { $folderName = basename($path); $folderPath = dirname($path); $path = self::validatePath($folderPath); $fullPath = $this->getMediaPath($path); $folders = $this->getStorageDisk()->directories($fullPath); foreach ($folders as $folder) { if (basename($folder) == $folderName) { return true; } } return false; } /** * Returns a list of all directories in the Library, optionally excluding some of them. * @param array $exclude A list of folders to exclude from the result list. * The folder paths should be specified relative to the Library root. * @return array */ public function listAllDirectories($exclude = []) { $fullPath = $this->getMediaPath('/'); $folders = $this->getStorageDisk()->allDirectories($fullPath); $folders = array_unique($folders, SORT_LOCALE_STRING); $result = []; foreach ($folders as $folder) { $folder = $this->getMediaRelativePath($folder); if (!strlen($folder)) { $folder = '/'; } if (Str::startsWith($folder, $exclude)) { continue; } $result[] = $folder; } if (!in_array('/', $result)) { array_unshift($result, '/'); } return $result; } /** * Returns a file contents. * @param string $path Specifies the file path relative the the Library root. * @return string Returns the file contents */ public function get($path) { $path = self::validatePath($path); $fullPath = $this->getMediaPath($path); return $this->getStorageDisk()->get($fullPath); } /** * Puts a file to the library. * @param string $path Specifies the file path relative the the Library root. * @param string $contents Specifies the file contents. * @return boolean */ public function put($path, $contents) { $path = self::validatePath($path); $fullPath = $this->getMediaPath($path); return $this->getStorageDisk()->put($fullPath, $contents); } /** * Moves a file to another location. * @param string $oldPath Specifies the original path of the file. * @param string $newPath Specifies the new path of the file. * @return boolean */ public function moveFile($oldPath, $newPath, $isRename = false) { $oldPath = self::validatePath($oldPath); $fullOldPath = $this->getMediaPath($oldPath); $newPath = self::validatePath($newPath); $fullNewPath = $this->getMediaPath($newPath); return $this->getStorageDisk()->move($fullOldPath, $fullNewPath); } /** * Copies a folder. * @param string $originalPath Specifies the original path of the folder. * @param string $newPath Specifies the new path of the folder. * @return boolean */ public function copyFolder($originalPath, $newPath) { $disk = $this->getStorageDisk(); $copyDirectory = function ($srcPath, $destPath) use (&$copyDirectory, $disk) { $srcPath = self::validatePath($srcPath); $fullSrcPath = $this->getMediaPath($srcPath); $destPath = self::validatePath($destPath); $fullDestPath = $this->getMediaPath($destPath); if (!$disk->makeDirectory($fullDestPath)) { return false; } $folderContents = $this->scanFolderContents($fullSrcPath); foreach ($folderContents['folders'] as $dirInfo) { if (!$copyDirectory($dirInfo->path, $destPath.'/'.basename($dirInfo->path))) { return false; } } foreach ($folderContents['files'] as $fileInfo) { $fullFileSrcPath = $this->getMediaPath($fileInfo->path); if (!$disk->copy($fullFileSrcPath, $fullDestPath.'/'.basename($fileInfo->path))) { return false; } } return true; }; return $copyDirectory($originalPath, $newPath); } /** * Moves a folder. * @param string $originalPath Specifies the original path of the folder. * @param string $newPath Specifies the new path of the folder. * @return boolean */ public function moveFolder($originalPath, $newPath) { if (Str::lower($originalPath) !== Str::lower($newPath)) { // If there is no risk that the directory was renamed // by just changing the letter case in the name - // copy the directory to the destination path and delete // the source directory. if (!$this->copyFolder($originalPath, $newPath)) { return false; } $this->deleteFolder($originalPath); } else { // If there's a risk that the directory name was updated // by changing the letter case - swap source and destination // using a temporary directory with random name. $tempraryDirPath = $this->generateRandomTmpFolderName(dirname($originalPath)); if (!$this->copyFolder($originalPath, $tempraryDirPath)) { $this->deleteFolder($tempraryDirPath); return false; } $this->deleteFolder($originalPath); return $this->moveFolder($tempraryDirPath, $newPath); } return true; } /** * Creates a folder. * @param string $path Specifies the folder path. * @return boolean */ public function makeFolder($path) { $path = self::validatePath($path); $fullPath = $this->getMediaPath($path); return $this->getStorageDisk()->makeDirectory($fullPath); } /** * Resets the Library cache. * * The cache stores the library table of contents locally in order to optimize * the performance when working with remote storages. The default cache TTL is * 10 minutes. The cache is deleted automatically when an item is added, changed * or deleted. This method allows to reset the cache forcibly. */ public function resetCache() { Cache::forget(self::CACHE_KEY); } /** * Checks if file path doesn't contain any substrings that would pose a security threat. * Throws an exception if the path is not valid. * @param string $path Specifies the path. * @param boolean $normalizeOnly Specifies if only the normalization, without validation should be performed. * @return string Returns a normalized path. */ public static function validatePath($path, $normalizeOnly = false) { $path = str_replace('\\', '/', $path); $path = '/'.trim($path, '/'); if ($normalizeOnly) { return $path; } $regexDirectorySeparator = preg_quote('/', '#'); $regexDot = preg_quote('.', '#'); $regex = [ // Checks for parent or current directory reference at beginning of path '(^'.$regexDot.'+?'.$regexDirectorySeparator.')', // Check for parent or current directory reference in middle of path '('.$regexDirectorySeparator.$regexDot.'+?'.$regexDirectorySeparator.')', // Check for parent or current directory reference at end of path '('.$regexDirectorySeparator.$regexDot.'+?$)', ]; /* * Combine everything to one regex */ $regex = '#'.implode('|', $regex).'#'; if (preg_match($regex, $path) !== 0 || strpos($path, '//') !== false) { throw new ApplicationException(Lang::get('system::lang.media.invalid_path', compact('path'))); } return $path; } /** * Helper that makes a URL for a media file. * @param string $file * @return string */ public static function url($file) { return static::instance()->getPathUrl($file); } /** * Returns a public file URL. * @param string $path Specifies the file path relative the the Library root. * @return string */ public function getPathUrl($path) { $path = $this->validatePath($path); return $this->storagePath.$path; } /** * Returns a file or folder path with the prefixed storage folder. * @param string $path Specifies a path to process. * @return string Returns a processed string. */ protected function getMediaPath($path) { return $this->storageFolder.$path; } /** * Returns path relative to the Library root folder. * @param string $path Specifies a path relative to the Library disk root. * @return string Returns the updated path. */ protected function getMediaRelativePath($path) { $path = self::validatePath($path, true); if (substr($path, 0, $this->storageFolderNameLength) == $this->storageFolder) { return substr($path, $this->storageFolderNameLength); } throw new SystemException(sprintf('Cannot convert Media Library path "%s" to a path relative to the Library root.', $path)); } /** * Determines if the path should be visible (not ignored). * @param string $path Specifies a path to check. * @return boolean Returns TRUE if the path is visible. */ protected function isVisible($path) { $baseName = basename($path); if (in_array($baseName, $this->ignoreNames)) { return false; } foreach ($this->ignorePatterns as $pattern) { if (preg_match('/'.$pattern.'/', $baseName)) { return false; } } return true; } /** * Initializes a library item from a path and item type. * @param string $path Specifies the item path relative to the storage disk root. * @param string $itemType Specifies the item type. * @return mixed Returns the MediaLibraryItem object or NULL if the item is not visible. */ protected function initLibraryItem($path, $itemType) { $relativePath = $this->getMediaRelativePath($path); if (!$this->isVisible($relativePath)) { return; } /* * S3 doesn't allow getting the last modified timestamp for folders, * so this feature is disabled - folders timestamp is always NULL. */ $lastModified = $itemType == MediaLibraryItem::TYPE_FILE ? $this->getStorageDisk()->lastModified($path) : null; /* * The folder size (number of items) doesn't respect filters. That * could be confusing for users, but that's safer than displaying * zero items for a folder that contains files not visible with a * currently applied filter. -ab */ $size = $itemType == MediaLibraryItem::TYPE_FILE ? $this->getStorageDisk()->size($path) : $this->getFolderItemCount($path); $publicUrl = $this->storagePath.$relativePath; return new MediaLibraryItem($relativePath, $size, $lastModified, $itemType, $publicUrl); } /** * Returns a number of items on a folder. * @param string $path Specifies the folder path relative to the storage disk root. * @return integer Returns the number of items in the folder. */ protected function getFolderItemCount($path) { $folderItems = array_merge( $this->getStorageDisk()->files($path), $this->getStorageDisk()->directories($path) ); $size = 0; foreach ($folderItems as $folderItem) { if ($this->isVisible($folderItem)) { $size++; } } return $size; } /** * Fetches the contents of a folder from the Library. * @param string $fullFolderPath Specifies the folder path relative the the storage disk root. * @return array Returns an array containing two elements - 'files' and 'folders', each is an array of MediaLibraryItem objects. */ protected function scanFolderContents($fullFolderPath) { $result = [ 'files' => [], 'folders' => [] ]; $files = $this->getStorageDisk()->files($fullFolderPath); foreach ($files as $file) { if ($libraryItem = $this->initLibraryItem($file, MediaLibraryItem::TYPE_FILE)) { $result['files'][] = $libraryItem; } } $folders = $this->getStorageDisk()->directories($fullFolderPath); foreach ($folders as $folder) { if ($libraryItem = $this->initLibraryItem($folder, MediaLibraryItem::TYPE_FOLDER)) { $result['folders'][] = $libraryItem; } } return $result; } /** * Sorts the item list by title, size or last modified date. * @param array $itemList Specifies the item list to sort. * @param mixed $sortSettings Determines the sorting preference. * Supported values are 'title', 'size', 'lastModified' (see SORT_BY_XXX class constants) or an associative array with a 'by' key and a 'direction' key: ['by' => SORT_BY_XXX, 'direction' => SORT_DIRECTION_XXX]. */ protected function sortItemList(&$itemList, $sortSettings) { $files = []; $folders = []; // Convert string $sortBy to array if (is_string($sortSettings)) { $sortSettings = [ 'by' => $sortSettings, 'direction' => self::SORT_DIRECTION_ASC, ]; } usort($itemList, function ($a, $b) use ($sortSettings) { $result = 0; switch ($sortSettings['by']) { case self::SORT_BY_TITLE: $result = strcasecmp($a->path, $b->path); break; case self::SORT_BY_SIZE: if ($a->size < $b->size) { $result = -1; } else { $result = $a->size > $b->size ? 1 : 0; } break; case self::SORT_BY_MODIFIED: if ($a->lastModified < $b->lastModified) { $result = -1; } else { $result = $a->lastModified > $b->lastModified ? 1 : 0; } break; } // Reverse the polarity of the result to direct sorting in a descending order instead if ($sortSettings['direction'] === self::SORT_DIRECTION_DESC) { $result = 0 - $result; } return $result; }); } /** * Filters item list by file type. * @param array $itemList Specifies the item list to sort. * @param string $filter Determines the document type filtering preference. * Supported values are 'image', 'video', 'audio', 'document' (see FILE_TYPE_XXX constants of MediaLibraryItem class). */ protected function filterItemList(&$itemList, $filter) { if (!$filter) return; $result = []; foreach ($itemList as $item) { if ($item->getFileType() == $filter) { $result[] = $item; } } $itemList = $result; } /** * Initializes and returns the Media Library disk. * This method should always be used instead of trying to access the * $storageDisk property directly as initializing the disc requires * communicating with the remote storage. * @return mixed Returns the storage disk object. */ protected function getStorageDisk() { if ($this->storageDisk) { return $this->storageDisk; } return $this->storageDisk = Storage::disk( Config::get('cms.storage.media.disk', 'local') ); } /** * Determines if file path contains all words form the search term. * @param string $path Specifies a path to examine. * @param array $words A list of words to check against. * @return boolean */ protected function pathMatchesSearch($path, $words) { $path = Str::lower($path); foreach ($words as $word) { $word = trim($word); if (!strlen($word)) { continue; } if (!Str::contains($path, $word)) { return false; } } return true; } protected function generateRandomTmpFolderName($location) { $temporaryDirBaseName = time(); $tmpPath = $location.'/tmp-'.$temporaryDirBaseName; while ($this->folderExists($tmpPath)) { $temporaryDirBaseName++; $tmpPath = $location.'/tmp-'.$temporaryDirBaseName; } return $tmpPath; } }