xref: /webtrees/app/Services/MediaFileService.php (revision 6edebced666a7f4ff407adf7d8b940ca72dfdc8c)
1d4265d07SGreg Roach<?php
2d4265d07SGreg Roach
3d4265d07SGreg Roach/**
4d4265d07SGreg Roach * webtrees: online genealogy
55bfc6897SGreg Roach * Copyright (C) 2022 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
22b5c53c7fSGreg Roachuse Fisharebest\Webtrees\Exceptions\FileUploadException;
23d4265d07SGreg Roachuse Fisharebest\Webtrees\FlashMessages;
24d4265d07SGreg Roachuse Fisharebest\Webtrees\I18N;
25f7cf8a15SGreg Roachuse Fisharebest\Webtrees\Registry;
26d4265d07SGreg Roachuse Fisharebest\Webtrees\Tree;
27b55cbc6bSGreg Roachuse Fisharebest\Webtrees\Validator;
28d4265d07SGreg Roachuse Illuminate\Database\Capsule\Manager as DB;
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;
5375e2e2b6SGreg Roachuse function strlen;
540ea5fc1cSGreg Roachuse function strtoupper;
5545fc2659SGreg Roachuse function strtr;
56d501c45dSGreg Roachuse function substr;
57d4265d07SGreg Roachuse function trim;
5846b03695SGreg Roach
59d4265d07SGreg Roachuse const PATHINFO_EXTENSION;
6081aa9d16SGreg Roachuse const PHP_INT_MAX;
61d4265d07SGreg Roachuse const UPLOAD_ERR_OK;
62d4265d07SGreg Roach
63d4265d07SGreg Roach/**
64d4265d07SGreg Roach * Managing media files.
65d4265d07SGreg Roach */
66d4265d07SGreg Roachclass MediaFileService
67d4265d07SGreg Roach{
6845fc2659SGreg Roach    public const EXTENSION_TO_FORM = [
690ea5fc1cSGreg Roach        'JPEG' => 'JPG',
700ea5fc1cSGreg Roach        'TIFF' => 'TIF',
7145fc2659SGreg Roach    ];
7245fc2659SGreg Roach
73c6bad570SGreg Roach    private const IGNORE_FOLDERS = [
74c6bad570SGreg Roach        // Old versions of webtrees
75c6bad570SGreg Roach        'thumbs',
76c6bad570SGreg Roach        'watermarks',
77c6bad570SGreg Roach        // Windows
78c6bad570SGreg Roach        'Thumbs.db',
79c6bad570SGreg Roach        // Synology
80c6bad570SGreg Roach        '@eaDir',
81c6bad570SGreg Roach        // QNAP,
82c6bad570SGreg Roach        '.@__thumb',
832c7d07c0SGreg Roach        // WebDAV,
842c7d07c0SGreg Roach        '_DAV',
85c6bad570SGreg Roach    ];
86c6bad570SGreg Roach
87d4265d07SGreg Roach    /**
88d4265d07SGreg Roach     * What is the largest file a user may upload?
89d4265d07SGreg Roach     */
90d4265d07SGreg Roach    public function maxUploadFilesize(): string
91d4265d07SGreg Roach    {
92d8809d62SGreg Roach        $sizePostMax   = $this->parseIniFileSize((string) ini_get('post_max_size'));
93d8809d62SGreg Roach        $sizeUploadMax = $this->parseIniFileSize((string) ini_get('upload_max_filesize'));
94d501c45dSGreg Roach
95d501c45dSGreg Roach        $bytes = min($sizePostMax, $sizeUploadMax);
96d4265d07SGreg Roach        $kb    = intdiv($bytes + 1023, 1024);
97d4265d07SGreg Roach
98d4265d07SGreg Roach        return I18N::translate('%s KB', I18N::number($kb));
99d4265d07SGreg Roach    }
100d4265d07SGreg Roach
101d4265d07SGreg Roach    /**
102d501c45dSGreg Roach     * Returns the given size from an ini value in bytes.
103d501c45dSGreg Roach     *
104284014f8SGreg Roach     * @param string $size
105d501c45dSGreg Roach     *
106d501c45dSGreg Roach     * @return int
107d501c45dSGreg Roach     */
108284014f8SGreg Roach    private function parseIniFileSize(string $size): int
109d501c45dSGreg Roach    {
110d501c45dSGreg Roach        $number = (int) $size;
111d501c45dSGreg Roach
11281aa9d16SGreg Roach        $units = [
11381aa9d16SGreg Roach            'g' => 1073741824,
11481aa9d16SGreg Roach            'G' => 1073741824,
11581aa9d16SGreg Roach            'm' => 1048576,
11681aa9d16SGreg Roach            'M' => 1048576,
11781aa9d16SGreg Roach            'k' => 1024,
11881aa9d16SGreg Roach            'K' => 1024,
11981aa9d16SGreg Roach        ];
12081aa9d16SGreg Roach
12181aa9d16SGreg Roach        $number *= $units[substr($size, -1)] ?? 1;
12281aa9d16SGreg Roach
12381aa9d16SGreg Roach        if (is_float($number)) {
12481aa9d16SGreg Roach            // Probably a 32bit version of PHP, with an INI setting >= 2GB
12581aa9d16SGreg Roach            return PHP_INT_MAX;
126d501c45dSGreg Roach        }
12781aa9d16SGreg Roach
12881aa9d16SGreg Roach        return $number;
129d501c45dSGreg Roach    }
130d501c45dSGreg Roach
131d501c45dSGreg Roach    /**
132d4265d07SGreg Roach     * A list of media files not already linked to a media object.
133d4265d07SGreg Roach     *
134d4265d07SGreg Roach     * @param Tree               $tree
135f7cf8a15SGreg Roach     * @param FilesystemOperator $data_filesystem
136d4265d07SGreg Roach     *
137bfe98399SGreg Roach     * @return array<string>
138d4265d07SGreg Roach     */
139f7cf8a15SGreg Roach    public function unusedFiles(Tree $tree, FilesystemOperator $data_filesystem): array
140d4265d07SGreg Roach    {
141d4265d07SGreg Roach        $used_files = DB::table('media_file')
142d4265d07SGreg Roach            ->where('m_file', '=', $tree->id())
143d4265d07SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
144d4265d07SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
145d4265d07SGreg Roach            ->pluck('multimedia_file_refn')
146d4265d07SGreg Roach            ->all();
147d4265d07SGreg Roach
148c6bad570SGreg Roach        $media_filesystem = $tree->mediaFilesystem($data_filesystem);
149782714c2SGreg Roach        $disk_files       = $this->allFilesOnDisk($media_filesystem, '', FilesystemReader::LIST_DEEP)->all();
150d4265d07SGreg Roach        $unused_files     = array_diff($disk_files, $used_files);
151d4265d07SGreg Roach
152d4265d07SGreg Roach        sort($unused_files);
153d4265d07SGreg Roach
154d4265d07SGreg Roach        return array_combine($unused_files, $unused_files);
155d4265d07SGreg Roach    }
156d4265d07SGreg Roach
157d4265d07SGreg Roach    /**
158d4265d07SGreg Roach     * Store an uploaded file (or URL), either to be added to a media object
159d4265d07SGreg Roach     * or to create a media object.
160d4265d07SGreg Roach     *
161d4265d07SGreg Roach     * @param ServerRequestInterface $request
162d4265d07SGreg Roach     *
163d4265d07SGreg Roach     * @return string The value to be stored in the 'FILE' field of the media object.
164f7cf8a15SGreg Roach     * @throws FilesystemException
165d4265d07SGreg Roach     */
166d4265d07SGreg Roach    public function uploadFile(ServerRequestInterface $request): string
167d4265d07SGreg Roach    {
168b55cbc6bSGreg Roach        $tree = Validator::attributes($request)->tree();
169d4265d07SGreg Roach
1706b9cb339SGreg Roach        $data_filesystem = Registry::filesystem()->data();
171a04bb9a2SGreg Roach
172b46c87bdSGreg Roach        $params        = (array) $request->getParsedBody();
173d4265d07SGreg Roach        $file_location = $params['file_location'];
174d4265d07SGreg Roach
175d4265d07SGreg Roach        switch ($file_location) {
176d4265d07SGreg Roach            case 'url':
177d4265d07SGreg Roach                $remote = $params['remote'];
178d4265d07SGreg Roach
179dec352c1SGreg Roach                if (str_contains($remote, '://')) {
180d4265d07SGreg Roach                    return $remote;
181d4265d07SGreg Roach                }
182d4265d07SGreg Roach
183d4265d07SGreg Roach                return '';
184d4265d07SGreg Roach
185d4265d07SGreg Roach            case 'unused':
186d4265d07SGreg Roach                $unused = $params['unused'];
187d4265d07SGreg Roach
188f7cf8a15SGreg Roach                if ($tree->mediaFilesystem($data_filesystem)->fileExists($unused)) {
189d4265d07SGreg Roach                    return $unused;
190d4265d07SGreg Roach                }
191d4265d07SGreg Roach
192d4265d07SGreg Roach                return '';
193d4265d07SGreg Roach
194d4265d07SGreg Roach            case 'upload':
195d4265d07SGreg Roach            default:
196d4265d07SGreg Roach                $folder   = $params['folder'];
197d4265d07SGreg Roach                $auto     = $params['auto'];
198d4265d07SGreg Roach                $new_file = $params['new_file'];
199d4265d07SGreg Roach
200b5c53c7fSGreg Roach                $uploaded_file = $request->getUploadedFiles()['file'] ?? null;
201b5c53c7fSGreg Roach
202d4265d07SGreg Roach                if ($uploaded_file === null || $uploaded_file->getError() !== UPLOAD_ERR_OK) {
203b5c53c7fSGreg Roach                    throw new FileUploadException($uploaded_file);
204d4265d07SGreg Roach                }
205d4265d07SGreg Roach
206d4265d07SGreg Roach                // The filename
207dec352c1SGreg Roach                $new_file = strtr($new_file, ['\\' => '/']);
208dec352c1SGreg Roach                if ($new_file !== '' && !str_contains($new_file, '/')) {
209d4265d07SGreg Roach                    $file = $new_file;
210d4265d07SGreg Roach                } else {
211d4265d07SGreg Roach                    $file = $uploaded_file->getClientFilename();
212d4265d07SGreg Roach                }
213d4265d07SGreg Roach
214d4265d07SGreg Roach                // The folder
215dec352c1SGreg Roach                $folder = strtr($folder, ['\\' => '/']);
216d4265d07SGreg Roach                $folder = trim($folder, '/');
217d4265d07SGreg Roach                if ($folder !== '') {
218d4265d07SGreg Roach                    $folder .= '/';
219d4265d07SGreg Roach                }
220d4265d07SGreg Roach
221d4265d07SGreg Roach                // Generate a unique name for the file?
222f7cf8a15SGreg Roach                if ($auto === '1' || $tree->mediaFilesystem($data_filesystem)->fileExists($folder . $file)) {
223d4265d07SGreg Roach                    $folder    = '';
224d4265d07SGreg Roach                    $extension = pathinfo($uploaded_file->getClientFilename(), PATHINFO_EXTENSION);
225d4265d07SGreg Roach                    $file      = sha1((string) $uploaded_file->getStream()) . '.' . $extension;
226d4265d07SGreg Roach                }
227d4265d07SGreg Roach
228d4265d07SGreg Roach                try {
229f7cf8a15SGreg Roach                    $tree->mediaFilesystem($data_filesystem)->writeStream($folder . $file, $uploaded_file->getStream()->detach());
230d4265d07SGreg Roach
231d4265d07SGreg Roach                    return $folder . $file;
232d4265d07SGreg Roach                } catch (RuntimeException | InvalidArgumentException $ex) {
233d4265d07SGreg Roach                    FlashMessages::addMessage(I18N::translate('There was an error uploading your file.'));
234d4265d07SGreg Roach
235d4265d07SGreg Roach                    return '';
236d4265d07SGreg Roach                }
237d4265d07SGreg Roach        }
238d4265d07SGreg Roach    }
239d4265d07SGreg Roach
240d4265d07SGreg Roach    /**
241d4265d07SGreg Roach     * Convert the media file attributes into GEDCOM format.
242d4265d07SGreg Roach     *
243d4265d07SGreg Roach     * @param string $file
244d4265d07SGreg Roach     * @param string $type
245d4265d07SGreg Roach     * @param string $title
24645fc2659SGreg Roach     * @param string $note
247d4265d07SGreg Roach     *
248d4265d07SGreg Roach     * @return string
249d4265d07SGreg Roach     */
25045fc2659SGreg Roach    public function createMediaFileGedcom(string $file, string $type, string $title, string $note): string
251d4265d07SGreg Roach    {
252d4265d07SGreg Roach        $gedcom = '1 FILE ' . $file;
25345fc2659SGreg Roach
2542bba966dSGreg Roach        if (str_contains($file, '://')) {
2552bba966dSGreg Roach            $format = '';
2562bba966dSGreg Roach        } else {
2570ea5fc1cSGreg Roach            $format = strtoupper(pathinfo($file, PATHINFO_EXTENSION));
25845fc2659SGreg Roach            $format = self::EXTENSION_TO_FORM[$format] ?? $format;
2592bba966dSGreg Roach        }
26045fc2659SGreg Roach
26175e2e2b6SGreg Roach        if ($format !== '' && strlen($format) <= 4) {
26245fc2659SGreg Roach            $gedcom .= "\n2 FORM " . $format;
26345fc2659SGreg Roach        } elseif ($type !== '') {
26445fc2659SGreg Roach            $gedcom .= "\n2 FORM";
265d4265d07SGreg Roach        }
26645fc2659SGreg Roach
26745fc2659SGreg Roach        if ($type !== '') {
26845fc2659SGreg Roach            $gedcom .= "\n3 TYPE " . $type;
26945fc2659SGreg Roach        }
27045fc2659SGreg Roach
271d4265d07SGreg Roach        if ($title !== '') {
272d4265d07SGreg Roach            $gedcom .= "\n2 TITL " . $title;
273d4265d07SGreg Roach        }
274d4265d07SGreg Roach
27545fc2659SGreg Roach        if ($note !== '') {
27645fc2659SGreg Roach            // Convert HTML line endings to GEDCOM continuations
27745fc2659SGreg Roach            $gedcom .= "\n1 NOTE " . strtr($note, ["\r\n" => "\n2 CONT "]);
27845fc2659SGreg Roach        }
27945fc2659SGreg Roach
280d4265d07SGreg Roach        return $gedcom;
281d4265d07SGreg Roach    }
28213aa75d8SGreg Roach
28313aa75d8SGreg Roach    /**
28413aa75d8SGreg Roach     * Fetch a list of all files on disk (in folders used by any tree).
28513aa75d8SGreg Roach     *
286f7cf8a15SGreg Roach     * @param FilesystemOperator $filesystem $filesystem to search
287f7cf8a15SGreg Roach     * @param string             $folder     Root folder
28813aa75d8SGreg Roach     * @param bool               $subfolders Include subfolders
28913aa75d8SGreg Roach     *
29036779af1SGreg Roach     * @return Collection<int,string>
29113aa75d8SGreg Roach     */
292f7cf8a15SGreg Roach    public function allFilesOnDisk(FilesystemOperator $filesystem, string $folder, bool $subfolders): Collection
29313aa75d8SGreg Roach    {
294f0448b68SGreg Roach        try {
295f9b64f46SGreg Roach            $files = $filesystem
296f9b64f46SGreg Roach                ->listContents($folder, $subfolders)
297f9b64f46SGreg Roach                ->filter(fn (StorageAttributes $attributes): bool => $attributes->isFile())
298f9b64f46SGreg Roach                ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
299f9b64f46SGreg Roach                ->map(fn (StorageAttributes $attributes): string => $attributes->path())
300f0448b68SGreg Roach                ->toArray();
301f0448b68SGreg Roach        } catch (FilesystemException $ex) {
302f0448b68SGreg Roach            $files = [];
303f0448b68SGreg Roach        }
304f7cf8a15SGreg Roach
305f0448b68SGreg Roach        return new Collection($files);
30613aa75d8SGreg Roach    }
30713aa75d8SGreg Roach
30813aa75d8SGreg Roach    /**
30913aa75d8SGreg Roach     * Fetch a list of all files on in the database.
31013aa75d8SGreg Roach     *
31113aa75d8SGreg Roach     * @param string $media_folder Root folder
31213aa75d8SGreg Roach     * @param bool   $subfolders   Include subfolders
31313aa75d8SGreg Roach     *
31436779af1SGreg Roach     * @return Collection<int,string>
31513aa75d8SGreg Roach     */
31613aa75d8SGreg Roach    public function allFilesInDatabase(string $media_folder, bool $subfolders): Collection
31713aa75d8SGreg Roach    {
31813aa75d8SGreg Roach        $query = DB::table('media_file')
31913aa75d8SGreg Roach            ->join('gedcom_setting', 'gedcom_id', '=', 'm_file')
32013aa75d8SGreg Roach            ->where('setting_name', '=', 'MEDIA_DIRECTORY')
32113aa75d8SGreg Roach            //->where('multimedia_file_refn', 'LIKE', '%/%')
32213aa75d8SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
32313aa75d8SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
32413aa75d8SGreg Roach            ->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%')
32513aa75d8SGreg Roach            ->select(new Expression('setting_value || multimedia_file_refn AS path'))
32613aa75d8SGreg Roach            ->orderBy(new Expression('setting_value || multimedia_file_refn'));
32713aa75d8SGreg Roach
32813aa75d8SGreg Roach        if (!$subfolders) {
32913aa75d8SGreg Roach            $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%');
33013aa75d8SGreg Roach        }
33113aa75d8SGreg Roach
33213aa75d8SGreg Roach        return $query->pluck('path');
33313aa75d8SGreg Roach    }
33413aa75d8SGreg Roach
33513aa75d8SGreg Roach    /**
336f9b64f46SGreg Roach     * Generate a list of all folders used by a tree.
337f9b64f46SGreg Roach     *
338f9b64f46SGreg Roach     * @param Tree $tree
339f9b64f46SGreg Roach     *
34036779af1SGreg Roach     * @return Collection<int,string>
341f9b64f46SGreg Roach     * @throws FilesystemException
342f9b64f46SGreg Roach     */
343f9b64f46SGreg Roach    public function mediaFolders(Tree $tree): Collection
344f9b64f46SGreg Roach    {
345f9b64f46SGreg Roach        $folders = Registry::filesystem()->media($tree)
346782714c2SGreg Roach            ->listContents('', FilesystemReader::LIST_DEEP)
347f9b64f46SGreg Roach            ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir())
348f9b64f46SGreg Roach            ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
349f9b64f46SGreg Roach            ->map(fn (StorageAttributes $attributes): string => $attributes->path())
350f9b64f46SGreg Roach            ->toArray();
351f9b64f46SGreg Roach
352f9b64f46SGreg Roach        return new Collection($folders);
353f9b64f46SGreg Roach    }
354f9b64f46SGreg Roach
355f9b64f46SGreg Roach    /**
35613aa75d8SGreg Roach     * Generate a list of all folders in either the database or the filesystem.
35713aa75d8SGreg Roach     *
358f7cf8a15SGreg Roach     * @param FilesystemOperator $data_filesystem
35913aa75d8SGreg Roach     *
36036779af1SGreg Roach     * @return Collection<array-key,string>
361f7cf8a15SGreg Roach     * @throws FilesystemException
36213aa75d8SGreg Roach     */
363f7cf8a15SGreg Roach    public function allMediaFolders(FilesystemOperator $data_filesystem): Collection
36413aa75d8SGreg Roach    {
36513aa75d8SGreg Roach        $db_folders = DB::table('media_file')
366c6bad570SGreg Roach            ->leftJoin('gedcom_setting', static function (JoinClause $join): void {
367c6bad570SGreg Roach                $join
368c6bad570SGreg Roach                    ->on('gedcom_id', '=', 'm_file')
369c6bad570SGreg Roach                    ->where('setting_name', '=', 'MEDIA_DIRECTORY');
370c6bad570SGreg Roach            })
37113aa75d8SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
37213aa75d8SGreg Roach            ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
373c6bad570SGreg Roach            ->select(new Expression("COALESCE(setting_value, 'media/') || multimedia_file_refn AS path"))
37413aa75d8SGreg Roach            ->pluck('path')
37513aa75d8SGreg Roach            ->map(static function (string $path): string {
37613aa75d8SGreg Roach                return dirname($path) . '/';
37713aa75d8SGreg Roach            });
37813aa75d8SGreg Roach
379c6bad570SGreg Roach        $media_roots = DB::table('gedcom')
380c6bad570SGreg Roach            ->leftJoin('gedcom_setting', static function (JoinClause $join): void {
381c6bad570SGreg Roach                $join
382c6bad570SGreg Roach                    ->on('gedcom.gedcom_id', '=', 'gedcom_setting.gedcom_id')
383c6bad570SGreg Roach                    ->where('setting_name', '=', 'MEDIA_DIRECTORY');
384c6bad570SGreg Roach            })
385c6bad570SGreg Roach            ->where('gedcom.gedcom_id', '>', '0')
386c6bad570SGreg Roach            ->pluck(new Expression("COALESCE(setting_value, 'media/')"))
3878c627a69SGreg Roach            ->uniqueStrict();
38813aa75d8SGreg Roach
38913aa75d8SGreg Roach        $disk_folders = new Collection($media_roots);
39013aa75d8SGreg Roach
39113aa75d8SGreg Roach        foreach ($media_roots as $media_folder) {
392f9b64f46SGreg Roach            $tmp = $data_filesystem
393782714c2SGreg Roach                ->listContents($media_folder, FilesystemReader::LIST_DEEP)
394f9b64f46SGreg Roach                ->filter(fn (StorageAttributes $attributes): bool => $attributes->isDir())
395f9b64f46SGreg Roach                ->filter(fn (StorageAttributes $attributes): bool => !$this->ignorePath($attributes->path()))
396f9b64f46SGreg Roach                ->map(fn (StorageAttributes $attributes): string => $attributes->path() . '/')
397f7cf8a15SGreg Roach                ->toArray();
39813aa75d8SGreg Roach
39913aa75d8SGreg Roach            $disk_folders = $disk_folders->concat($tmp);
40013aa75d8SGreg Roach        }
40113aa75d8SGreg Roach
40213aa75d8SGreg Roach        return $disk_folders->concat($db_folders)
4038c627a69SGreg Roach            ->uniqueStrict()
404*6edebcedSGreg Roach            ->sort(I18N::comparator())
40513aa75d8SGreg Roach            ->mapWithKeys(static function (string $folder): array {
40613aa75d8SGreg Roach                return [$folder => $folder];
40713aa75d8SGreg Roach            });
40813aa75d8SGreg Roach    }
409f7cf8a15SGreg Roach
410f7cf8a15SGreg Roach    /**
411f9b64f46SGreg Roach     * Ignore special media folders.
41208b5db2aSGreg Roach     *
41308b5db2aSGreg Roach     * @param string $path
41408b5db2aSGreg Roach     *
41508b5db2aSGreg Roach     * @return bool
416f7cf8a15SGreg Roach     */
417c6bad570SGreg Roach    private function ignorePath(string $path): bool
418f7cf8a15SGreg Roach    {
419d8809d62SGreg Roach        return array_intersect(self::IGNORE_FOLDERS, explode('/', $path)) !== [];
420f7cf8a15SGreg Roach    }
421d4265d07SGreg Roach}
422