1d4265d07SGreg Roach<?php 2d4265d07SGreg Roach 3d4265d07SGreg Roach/** 4d4265d07SGreg Roach * webtrees: online genealogy 5d11be702SGreg Roach * Copyright (C) 2023 webtrees development team 6d4265d07SGreg Roach * This program is free software: you can redistribute it and/or modify 7d4265d07SGreg Roach * it under the terms of the GNU General Public License as published by 8d4265d07SGreg Roach * the Free Software Foundation, either version 3 of the License, or 9d4265d07SGreg Roach * (at your option) any later version. 10d4265d07SGreg Roach * This program is distributed in the hope that it will be useful, 11d4265d07SGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of 12d4265d07SGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13d4265d07SGreg Roach * GNU General Public License for more details. 14d4265d07SGreg Roach * You should have received a copy of the GNU General Public License 1589f7189bSGreg Roach * along with this program. If not, see <https://www.gnu.org/licenses/>. 16d4265d07SGreg Roach */ 17d4265d07SGreg Roach 18d4265d07SGreg Roachdeclare(strict_types=1); 19d4265d07SGreg Roach 20d4265d07SGreg Roachnamespace Fisharebest\Webtrees\Services; 21d4265d07SGreg Roach 226f4ec3caSGreg Roachuse Fisharebest\Webtrees\DB; 23b5c53c7fSGreg Roachuse Fisharebest\Webtrees\Exceptions\FileUploadException; 24d4265d07SGreg Roachuse Fisharebest\Webtrees\FlashMessages; 25d4265d07SGreg Roachuse Fisharebest\Webtrees\I18N; 2603f0aef8SGreg Roachuse Fisharebest\Webtrees\Registry; 27d4265d07SGreg Roachuse Fisharebest\Webtrees\Tree; 28b55cbc6bSGreg Roachuse Fisharebest\Webtrees\Validator; 2913aa75d8SGreg Roachuse Illuminate\Database\Query\Expression; 30c6bad570SGreg Roachuse Illuminate\Database\Query\JoinClause; 3113aa75d8SGreg Roachuse Illuminate\Support\Collection; 32d4265d07SGreg Roachuse InvalidArgumentException; 33f7cf8a15SGreg Roachuse League\Flysystem\FilesystemException; 34f7cf8a15SGreg Roachuse League\Flysystem\FilesystemOperator; 35782714c2SGreg Roachuse League\Flysystem\FilesystemReader; 36f7cf8a15SGreg Roachuse League\Flysystem\StorageAttributes; 37d4265d07SGreg Roachuse Psr\Http\Message\ServerRequestInterface; 38d4265d07SGreg Roachuse RuntimeException; 39d4265d07SGreg Roach 40d4265d07SGreg Roachuse function array_combine; 41d4265d07SGreg Roachuse function array_diff; 42f9b64f46SGreg Roachuse function array_intersect; 4313aa75d8SGreg Roachuse function dirname; 44f9b64f46SGreg Roachuse function explode; 45d501c45dSGreg Roachuse function ini_get; 46d4265d07SGreg Roachuse function intdiv; 4781aa9d16SGreg Roachuse function is_float; 48d501c45dSGreg Roachuse function min; 49d4265d07SGreg Roachuse function pathinfo; 50d4265d07SGreg Roachuse function sha1; 51d4265d07SGreg Roachuse function sort; 52dec352c1SGreg Roachuse function str_contains; 530ea5fc1cSGreg Roachuse function strtoupper; 5445fc2659SGreg Roachuse function strtr; 55d501c45dSGreg Roachuse function substr; 56d4265d07SGreg Roachuse function trim; 5746b03695SGreg Roach 58d4265d07SGreg Roachuse const PATHINFO_EXTENSION; 5981aa9d16SGreg Roachuse const PHP_INT_MAX; 60d4265d07SGreg Roachuse const UPLOAD_ERR_OK; 61d4265d07SGreg Roach 62d4265d07SGreg Roach/** 63d4265d07SGreg Roach * Managing media files. 64d4265d07SGreg Roach */ 65d4265d07SGreg Roachclass MediaFileService 66d4265d07SGreg Roach{ 67c6bad570SGreg Roach private const IGNORE_FOLDERS = [ 68c6bad570SGreg Roach // Old versions of webtrees 69c6bad570SGreg Roach 'thumbs', 70c6bad570SGreg Roach 'watermarks', 71c6bad570SGreg Roach // Windows 72c6bad570SGreg Roach 'Thumbs.db', 73c6bad570SGreg Roach // Synology 74c6bad570SGreg Roach '@eaDir', 75c6bad570SGreg Roach // QNAP, 76c6bad570SGreg Roach '.@__thumb', 772c7d07c0SGreg Roach // WebDAV, 782c7d07c0SGreg Roach '_DAV', 79c6bad570SGreg Roach ]; 80c6bad570SGreg Roach 81d4265d07SGreg Roach /** 82d4265d07SGreg Roach * What is the largest file a user may upload? 83d4265d07SGreg Roach */ 84d4265d07SGreg Roach public function maxUploadFilesize(): string 85d4265d07SGreg Roach { 86d8809d62SGreg Roach $sizePostMax = $this->parseIniFileSize((string) ini_get('post_max_size')); 87d8809d62SGreg Roach $sizeUploadMax = $this->parseIniFileSize((string) ini_get('upload_max_filesize')); 88d501c45dSGreg Roach 89d501c45dSGreg Roach $bytes = min($sizePostMax, $sizeUploadMax); 90d4265d07SGreg Roach $kb = intdiv($bytes + 1023, 1024); 91d4265d07SGreg Roach 92d4265d07SGreg Roach return I18N::translate('%s KB', I18N::number($kb)); 93d4265d07SGreg Roach } 94d4265d07SGreg Roach 95d4265d07SGreg Roach /** 96d501c45dSGreg Roach * Returns the given size from an ini value in bytes. 97d501c45dSGreg Roach * 98284014f8SGreg Roach * @param string $size 99d501c45dSGreg Roach * 100d501c45dSGreg Roach * @return int 101d501c45dSGreg Roach */ 102284014f8SGreg Roach private function parseIniFileSize(string $size): int 103d501c45dSGreg Roach { 104d501c45dSGreg Roach $number = (int) $size; 105d501c45dSGreg Roach 10681aa9d16SGreg Roach $units = [ 10781aa9d16SGreg Roach 'g' => 1073741824, 10881aa9d16SGreg Roach 'G' => 1073741824, 10981aa9d16SGreg Roach 'm' => 1048576, 11081aa9d16SGreg Roach 'M' => 1048576, 11181aa9d16SGreg Roach 'k' => 1024, 11281aa9d16SGreg Roach 'K' => 1024, 11381aa9d16SGreg Roach ]; 11481aa9d16SGreg Roach 11581aa9d16SGreg Roach $number *= $units[substr($size, -1)] ?? 1; 11681aa9d16SGreg Roach 11781aa9d16SGreg Roach if (is_float($number)) { 11881aa9d16SGreg Roach // Probably a 32bit version of PHP, with an INI setting >= 2GB 11981aa9d16SGreg Roach return PHP_INT_MAX; 120d501c45dSGreg Roach } 12181aa9d16SGreg Roach 12281aa9d16SGreg Roach return $number; 123d501c45dSGreg Roach } 124d501c45dSGreg Roach 125d501c45dSGreg Roach /** 126d4265d07SGreg Roach * A list of media files not already linked to a media object. 127d4265d07SGreg Roach * 128d4265d07SGreg Roach * @param Tree $tree 129d4265d07SGreg Roach * 130bfe98399SGreg Roach * @return array<string> 131d4265d07SGreg Roach */ 1329458f20aSGreg Roach public function unusedFiles(Tree $tree): array 133d4265d07SGreg Roach { 134d4265d07SGreg Roach $used_files = DB::table('media_file') 135d4265d07SGreg Roach ->where('m_file', '=', $tree->id()) 136d4265d07SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'http://%') 137d4265d07SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'https://%') 138d4265d07SGreg Roach ->pluck('multimedia_file_refn') 139d4265d07SGreg Roach ->all(); 140d4265d07SGreg Roach 1419458f20aSGreg Roach $media_filesystem = $tree->mediaFilesystem(); 142782714c2SGreg Roach $disk_files = $this->allFilesOnDisk($media_filesystem, '', FilesystemReader::LIST_DEEP)->all(); 143d4265d07SGreg Roach $unused_files = array_diff($disk_files, $used_files); 144d4265d07SGreg Roach 145d4265d07SGreg Roach sort($unused_files); 146d4265d07SGreg Roach 147d4265d07SGreg Roach return array_combine($unused_files, $unused_files); 148d4265d07SGreg Roach } 149d4265d07SGreg Roach 150d4265d07SGreg Roach /** 151d4265d07SGreg Roach * Store an uploaded file (or URL), either to be added to a media object 152d4265d07SGreg Roach * or to create a media object. 153d4265d07SGreg Roach * 154d4265d07SGreg Roach * @param ServerRequestInterface $request 155d4265d07SGreg Roach * 156d4265d07SGreg Roach * @return string The value to be stored in the 'FILE' field of the media object. 157f7cf8a15SGreg Roach * @throws FilesystemException 158d4265d07SGreg Roach */ 159d4265d07SGreg Roach public function uploadFile(ServerRequestInterface $request): string 160d4265d07SGreg Roach { 161b55cbc6bSGreg Roach $tree = Validator::attributes($request)->tree(); 162748dbe15SGreg Roach $file_location = Validator::parsedBody($request)->string('file_location'); 163d4265d07SGreg Roach 164d4265d07SGreg Roach switch ($file_location) { 165d4265d07SGreg Roach case 'url': 166748dbe15SGreg Roach $remote = Validator::parsedBody($request)->string('remote'); 167d4265d07SGreg Roach 168dec352c1SGreg Roach if (str_contains($remote, '://')) { 169d4265d07SGreg Roach return $remote; 170d4265d07SGreg Roach } 171d4265d07SGreg Roach 172d4265d07SGreg Roach return ''; 173d4265d07SGreg Roach 174d4265d07SGreg Roach case 'unused': 175748dbe15SGreg Roach $unused = Validator::parsedBody($request)->string('unused'); 176d4265d07SGreg Roach 1779458f20aSGreg Roach if ($tree->mediaFilesystem()->fileExists($unused)) { 178d4265d07SGreg Roach return $unused; 179d4265d07SGreg Roach } 180d4265d07SGreg Roach 181d4265d07SGreg Roach return ''; 182d4265d07SGreg Roach 183d4265d07SGreg Roach case 'upload': 184748dbe15SGreg Roach $folder = Validator::parsedBody($request)->string('folder'); 185748dbe15SGreg Roach $auto = Validator::parsedBody($request)->string('auto'); 186748dbe15SGreg Roach $new_file = Validator::parsedBody($request)->string('new_file'); 187d4265d07SGreg Roach 188b5c53c7fSGreg Roach $uploaded_file = $request->getUploadedFiles()['file'] ?? null; 189b5c53c7fSGreg Roach 190d4265d07SGreg Roach if ($uploaded_file === null || $uploaded_file->getError() !== UPLOAD_ERR_OK) { 191b5c53c7fSGreg Roach throw new FileUploadException($uploaded_file); 192d4265d07SGreg Roach } 193d4265d07SGreg Roach 194d4265d07SGreg Roach // The filename 195dec352c1SGreg Roach $new_file = strtr($new_file, ['\\' => '/']); 196dec352c1SGreg Roach if ($new_file !== '' && !str_contains($new_file, '/')) { 197d4265d07SGreg Roach $file = $new_file; 198d4265d07SGreg Roach } else { 199d4265d07SGreg Roach $file = $uploaded_file->getClientFilename(); 200d4265d07SGreg Roach } 201d4265d07SGreg Roach 202d4265d07SGreg Roach // The folder 203dec352c1SGreg Roach $folder = strtr($folder, ['\\' => '/']); 204d4265d07SGreg Roach $folder = trim($folder, '/'); 205d4265d07SGreg Roach if ($folder !== '') { 206d4265d07SGreg Roach $folder .= '/'; 207d4265d07SGreg Roach } 208d4265d07SGreg Roach 209d4265d07SGreg Roach // Generate a unique name for the file? 2109458f20aSGreg Roach if ($auto === '1' || $tree->mediaFilesystem()->fileExists($folder . $file)) { 211d4265d07SGreg Roach $folder = ''; 212d4265d07SGreg Roach $extension = pathinfo($uploaded_file->getClientFilename(), PATHINFO_EXTENSION); 213d4265d07SGreg Roach $file = sha1((string) $uploaded_file->getStream()) . '.' . $extension; 214d4265d07SGreg Roach } 215d4265d07SGreg Roach 216d4265d07SGreg Roach try { 2179458f20aSGreg Roach $tree->mediaFilesystem()->writeStream($folder . $file, $uploaded_file->getStream()->detach()); 218d4265d07SGreg Roach 219d4265d07SGreg Roach return $folder . $file; 22028d026adSGreg Roach } catch (RuntimeException | InvalidArgumentException) { 221d4265d07SGreg Roach FlashMessages::addMessage(I18N::translate('There was an error uploading your file.')); 222d4265d07SGreg Roach 223d4265d07SGreg Roach return ''; 224d4265d07SGreg Roach } 225d4265d07SGreg Roach } 226748dbe15SGreg Roach 227748dbe15SGreg Roach return ''; 228d4265d07SGreg Roach } 229d4265d07SGreg Roach 230d4265d07SGreg Roach /** 231d4265d07SGreg Roach * Convert the media file attributes into GEDCOM format. 232d4265d07SGreg Roach * 233d4265d07SGreg Roach * @param string $file 234d4265d07SGreg Roach * @param string $type 235d4265d07SGreg Roach * @param string $title 23645fc2659SGreg Roach * @param string $note 237d4265d07SGreg Roach * 238d4265d07SGreg Roach * @return string 239d4265d07SGreg Roach */ 24045fc2659SGreg Roach public function createMediaFileGedcom(string $file, string $type, string $title, string $note): string 241d4265d07SGreg Roach { 242d4265d07SGreg Roach $gedcom = '1 FILE ' . $file; 24345fc2659SGreg Roach 2442bba966dSGreg Roach if (str_contains($file, '://')) { 2452bba966dSGreg Roach $format = ''; 2462bba966dSGreg Roach } else { 2470ea5fc1cSGreg Roach $format = strtoupper(pathinfo($file, PATHINFO_EXTENSION)); 24803f0aef8SGreg Roach $format = Registry::elementFactory()->make('OBJE:FILE:FORM')->canonical($format); 2492bba966dSGreg Roach } 25045fc2659SGreg Roach 25103f0aef8SGreg Roach if ($format !== '') { 25203f0aef8SGreg Roach $gedcom .= "\n2 FORM " . strtr($format, ["\n" => "\n3 CONT "]); 25345fc2659SGreg Roach } elseif ($type !== '') { 25445fc2659SGreg Roach $gedcom .= "\n2 FORM"; 255d4265d07SGreg Roach } 25645fc2659SGreg Roach 25745fc2659SGreg Roach if ($type !== '') { 25803f0aef8SGreg Roach $gedcom .= "\n3 TYPE " . strtr($type, ["\n" => "\n4 CONT "]); 25945fc2659SGreg Roach } 26045fc2659SGreg Roach 261d4265d07SGreg Roach if ($title !== '') { 26203f0aef8SGreg Roach $gedcom .= "\n2 TITL " . strtr($title, ["\n" => "\n3 CONT "]); 263d4265d07SGreg Roach } 264d4265d07SGreg Roach 26545fc2659SGreg Roach if ($note !== '') { 26645fc2659SGreg Roach // Convert HTML line endings to GEDCOM continuations 26703f0aef8SGreg Roach $gedcom .= "\n1 NOTE " . strtr($note, ["\n" => "\n2 CONT "]); 26845fc2659SGreg Roach } 26945fc2659SGreg Roach 270d4265d07SGreg Roach return $gedcom; 271d4265d07SGreg Roach } 27213aa75d8SGreg Roach 27313aa75d8SGreg Roach /** 27413aa75d8SGreg Roach * Fetch a list of all files on disk (in folders used by any tree). 27513aa75d8SGreg Roach * 276f7cf8a15SGreg Roach * @param FilesystemOperator $filesystem $filesystem to search 277f7cf8a15SGreg Roach * @param string $folder Root folder 27813aa75d8SGreg Roach * @param bool $subfolders Include subfolders 27913aa75d8SGreg Roach * 28036779af1SGreg Roach * @return Collection<int,string> 28113aa75d8SGreg Roach */ 282f7cf8a15SGreg Roach public function allFilesOnDisk(FilesystemOperator $filesystem, string $folder, bool $subfolders): Collection 28313aa75d8SGreg Roach { 284f0448b68SGreg Roach try { 285f9b64f46SGreg Roach $files = $filesystem 286f9b64f46SGreg Roach ->listContents($folder, $subfolders) 287f9b64f46SGreg Roach ->filter(fn (StorageAttributes $attributes): bool => $attributes->isFile()) 288f9b64f46SGreg Roach ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path())) 289f9b64f46SGreg Roach ->map(fn (StorageAttributes $attributes): string => $attributes->path()) 290f0448b68SGreg Roach ->toArray(); 29128d026adSGreg Roach } catch (FilesystemException) { 292f0448b68SGreg Roach $files = []; 293f0448b68SGreg Roach } 294f7cf8a15SGreg Roach 295f0448b68SGreg Roach return new Collection($files); 29613aa75d8SGreg Roach } 29713aa75d8SGreg Roach 29813aa75d8SGreg Roach /** 29913aa75d8SGreg Roach * Fetch a list of all files on in the database. 30013aa75d8SGreg Roach * 30113aa75d8SGreg Roach * @param string $media_folder Root folder 30213aa75d8SGreg Roach * @param bool $subfolders Include subfolders 30313aa75d8SGreg Roach * 30436779af1SGreg Roach * @return Collection<int,string> 30513aa75d8SGreg Roach */ 30613aa75d8SGreg Roach public function allFilesInDatabase(string $media_folder, bool $subfolders): Collection 30713aa75d8SGreg Roach { 30813aa75d8SGreg Roach $query = DB::table('media_file') 30913aa75d8SGreg Roach ->join('gedcom_setting', 'gedcom_id', '=', 'm_file') 31013aa75d8SGreg Roach ->where('setting_name', '=', 'MEDIA_DIRECTORY') 31113aa75d8SGreg Roach //->where('multimedia_file_refn', 'LIKE', '%/%') 31213aa75d8SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'http://%') 31313aa75d8SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'https://%') 314059898c9SGreg Roach ->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%'); 31513aa75d8SGreg Roach 31613aa75d8SGreg Roach if (!$subfolders) { 31713aa75d8SGreg Roach $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%'); 31813aa75d8SGreg Roach } 31913aa75d8SGreg Roach 320059898c9SGreg Roach return $query 321059898c9SGreg Roach ->orderBy(new Expression('setting_value || multimedia_file_refn')) 322059898c9SGreg Roach ->pluck(new Expression('setting_value || multimedia_file_refn AS path')); 32313aa75d8SGreg Roach } 32413aa75d8SGreg Roach 32513aa75d8SGreg Roach /** 326f9b64f46SGreg Roach * Generate a list of all folders used by a tree. 327f9b64f46SGreg Roach * 328f9b64f46SGreg Roach * @param Tree $tree 329f9b64f46SGreg Roach * 33036779af1SGreg Roach * @return Collection<int,string> 331f9b64f46SGreg Roach * @throws FilesystemException 332f9b64f46SGreg Roach */ 333f9b64f46SGreg Roach public function mediaFolders(Tree $tree): Collection 334f9b64f46SGreg Roach { 3359458f20aSGreg Roach $folders = $tree->mediaFilesystem() 336782714c2SGreg Roach ->listContents('', FilesystemReader::LIST_DEEP) 337f9b64f46SGreg Roach ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir()) 338f9b64f46SGreg Roach ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path())) 339f9b64f46SGreg Roach ->map(fn (StorageAttributes $attributes): string => $attributes->path()) 340f9b64f46SGreg Roach ->toArray(); 341f9b64f46SGreg Roach 342f9b64f46SGreg Roach return new Collection($folders); 343f9b64f46SGreg Roach } 344f9b64f46SGreg Roach 345f9b64f46SGreg Roach /** 34613aa75d8SGreg Roach * Generate a list of all folders in either the database or the filesystem. 34713aa75d8SGreg Roach * 348f7cf8a15SGreg Roach * @param FilesystemOperator $data_filesystem 34913aa75d8SGreg Roach * 35036779af1SGreg Roach * @return Collection<array-key,string> 351f7cf8a15SGreg Roach * @throws FilesystemException 35213aa75d8SGreg Roach */ 353f7cf8a15SGreg Roach public function allMediaFolders(FilesystemOperator $data_filesystem): Collection 35413aa75d8SGreg Roach { 35513aa75d8SGreg Roach $db_folders = DB::table('media_file') 356c6bad570SGreg Roach ->leftJoin('gedcom_setting', static function (JoinClause $join): void { 357c6bad570SGreg Roach $join 358c6bad570SGreg Roach ->on('gedcom_id', '=', 'm_file') 359c6bad570SGreg Roach ->where('setting_name', '=', 'MEDIA_DIRECTORY'); 360c6bad570SGreg Roach }) 36113aa75d8SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'http://%') 36213aa75d8SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'https://%') 363059898c9SGreg Roach ->pluck(new Expression("COALESCE(setting_value, 'media/') || multimedia_file_refn AS path")) 364*f25fc0f9SGreg Roach ->map(static fn (string $path): string => dirname($path) . '/'); 36513aa75d8SGreg Roach 366c6bad570SGreg Roach $media_roots = DB::table('gedcom') 367c6bad570SGreg Roach ->leftJoin('gedcom_setting', static function (JoinClause $join): void { 368c6bad570SGreg Roach $join 369c6bad570SGreg Roach ->on('gedcom.gedcom_id', '=', 'gedcom_setting.gedcom_id') 370c6bad570SGreg Roach ->where('setting_name', '=', 'MEDIA_DIRECTORY'); 371c6bad570SGreg Roach }) 372c6bad570SGreg Roach ->where('gedcom.gedcom_id', '>', '0') 3732a0263cdSGreg Roach ->pluck(new Expression("COALESCE(setting_value, 'media/') AS path")) 3748c627a69SGreg Roach ->uniqueStrict(); 37513aa75d8SGreg Roach 37613aa75d8SGreg Roach $disk_folders = new Collection($media_roots); 37713aa75d8SGreg Roach 37813aa75d8SGreg Roach foreach ($media_roots as $media_folder) { 379f9b64f46SGreg Roach $tmp = $data_filesystem 380782714c2SGreg Roach ->listContents($media_folder, FilesystemReader::LIST_DEEP) 381f9b64f46SGreg Roach ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir()) 382f9b64f46SGreg Roach ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path())) 383f9b64f46SGreg Roach ->map(fn (StorageAttributes $attributes): string => $attributes->path() . '/') 384f7cf8a15SGreg Roach ->toArray(); 38513aa75d8SGreg Roach 38613aa75d8SGreg Roach $disk_folders = $disk_folders->concat($tmp); 38713aa75d8SGreg Roach } 38813aa75d8SGreg Roach 38913aa75d8SGreg Roach return $disk_folders->concat($db_folders) 3908c627a69SGreg Roach ->uniqueStrict() 3916edebcedSGreg Roach ->sort(I18N::comparator()) 392*f25fc0f9SGreg Roach ->mapWithKeys(static fn (string $folder): array => [$folder => $folder]); 39313aa75d8SGreg Roach } 394f7cf8a15SGreg Roach 395f7cf8a15SGreg Roach /** 396f9b64f46SGreg Roach * Ignore special media folders. 39708b5db2aSGreg Roach * 39808b5db2aSGreg Roach * @param string $path 39908b5db2aSGreg Roach * 40008b5db2aSGreg Roach * @return bool 401f7cf8a15SGreg Roach */ 402c6bad570SGreg Roach private function ignorePath(string $path): bool 403f7cf8a15SGreg Roach { 404d8809d62SGreg Roach return array_intersect(self::IGNORE_FOLDERS, explode('/', $path)) !== []; 405f7cf8a15SGreg Roach } 406d4265d07SGreg Roach} 407