xref: /webtrees/app/Http/RequestHandlers/ManageMediaData.php (revision 59a6bd4ad405c01c16d2ad2999655ba1e44be99c)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2023 webtrees development team
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Http\RequestHandlers;
21
22use Fisharebest\Webtrees\DB;
23use Fisharebest\Webtrees\Http\Exceptions\HttpNotFoundException;
24use Fisharebest\Webtrees\I18N;
25use Fisharebest\Webtrees\Media;
26use Fisharebest\Webtrees\Mime;
27use Fisharebest\Webtrees\Registry;
28use Fisharebest\Webtrees\Services\DatatablesService;
29use Fisharebest\Webtrees\Services\LinkedRecordService;
30use Fisharebest\Webtrees\Services\MediaFileService;
31use Fisharebest\Webtrees\Services\TreeService;
32use Fisharebest\Webtrees\Validator;
33use Illuminate\Database\Query\Builder;
34use Illuminate\Database\Query\Expression;
35use Illuminate\Database\Query\JoinClause;
36use League\Flysystem\FilesystemException;
37use League\Flysystem\FilesystemOperator;
38use League\Flysystem\UnableToCheckFileExistence;
39use League\Flysystem\UnableToReadFile;
40use League\Flysystem\UnableToRetrieveMetadata;
41use Psr\Http\Message\ResponseInterface;
42use Psr\Http\Message\ServerRequestInterface;
43use Psr\Http\Server\RequestHandlerInterface;
44use Throwable;
45
46use function assert;
47use function e;
48use function getimagesizefromstring;
49use function intdiv;
50use function route;
51use function str_starts_with;
52use function strlen;
53use function substr;
54use function view;
55
56/**
57 * Manage media from the control panel.
58 */
59class ManageMediaData implements RequestHandlerInterface
60{
61    private DatatablesService $datatables_service;
62
63    private LinkedRecordService $linked_record_service;
64
65    private MediaFileService $media_file_service;
66
67    private TreeService $tree_service;
68
69    /**
70     * @param DatatablesService   $datatables_service
71     * @param LinkedRecordService $linked_record_service
72     * @param MediaFileService    $media_file_service
73     * @param TreeService         $tree_service
74     */
75    public function __construct(
76        DatatablesService $datatables_service,
77        LinkedRecordService $linked_record_service,
78        MediaFileService $media_file_service,
79        TreeService $tree_service
80    ) {
81        $this->datatables_service    = $datatables_service;
82        $this->linked_record_service = $linked_record_service;
83        $this->media_file_service    = $media_file_service;
84        $this->tree_service          = $tree_service;
85    }
86
87    /**
88     * @param ServerRequestInterface $request
89     *
90     * @return ResponseInterface
91     */
92    public function handle(ServerRequestInterface $request): ResponseInterface
93    {
94        $data_filesystem = Registry::filesystem()->data();
95
96        $files = Validator::queryParams($request)->isInArray(['local', 'external', 'unused'])->string('files');
97
98        // Files within this folder
99        $media_folders = $this->media_file_service->allMediaFolders($data_filesystem)->all();
100        $media_folder  = Validator::queryParams($request)->isInArray($media_folders)->string('media_folder');
101
102        // Show sub-folders within $media_folder
103        $subfolders = Validator::queryParams($request)->isInArray(['include', 'exclude'])->string('subfolders');
104
105        $search_columns = ['multimedia_file_refn', 'descriptive_title'];
106
107        $sort_columns = [
108            0 => 'multimedia_file_refn',
109            2 => new Expression('descriptive_title || multimedia_file_refn'),
110        ];
111
112        // Convert a row from the database into a row for datatables
113        $callback = function (object $row): array {
114            $tree  = $this->tree_service->find((int) $row->m_file);
115            $media = Registry::mediaFactory()->make($row->m_id, $tree, $row->m_gedcom);
116            assert($media instanceof Media);
117
118            $is_http  = str_starts_with($row->multimedia_file_refn, 'http://');
119            $is_https = str_starts_with($row->multimedia_file_refn, 'https://');
120
121            if ($is_http || $is_https) {
122                return [
123                    '<a href="' . e($row->multimedia_file_refn) . '">' . e($row->multimedia_file_refn) . '</a>',
124                    view('icons/mime', ['type' => Mime::DEFAULT_TYPE]),
125                    $this->mediaObjectInfo($media),
126                ];
127            }
128
129            try {
130                $path = $row->media_folder . $row->multimedia_file_refn;
131
132                try {
133                    $mime_type = Registry::filesystem()->data()->mimeType($path);
134                } catch (UnableToRetrieveMetadata) {
135                    $mime_type = Mime::DEFAULT_TYPE;
136                }
137
138                if (str_starts_with($mime_type, 'image/')) {
139                    $url = route(AdminMediaFileThumbnail::class, ['path' => $path]);
140                    $img = '<img src="' . e($url) . '">';
141                } else {
142                    $img = view('icons/mime', ['type' => $mime_type]);
143                }
144
145                $url = route(AdminMediaFileDownload::class, ['path' => $path]);
146                $img = '<a href="' . e($url) . '" type="' . $mime_type . '" class="gallery">' . $img . '</a>';
147            } catch (UnableToReadFile) {
148                $url = route(AdminMediaFileThumbnail::class, ['path' => $path]);
149                $img = '<img src="' . e($url) . '">';
150            }
151
152            return [
153                e($row->multimedia_file_refn),
154                $img,
155                $this->mediaObjectInfo($media),
156            ];
157        };
158
159        switch ($files) {
160            case 'local':
161                $query = DB::table('media_file')
162                    ->join('media', static function (JoinClause $join): void {
163                        $join
164                            ->on('media.m_file', '=', 'media_file.m_file')
165                            ->on('media.m_id', '=', 'media_file.m_id');
166                    })
167                    ->leftJoin('gedcom_setting', static function (JoinClause $join): void {
168                        $join
169                            ->on('gedcom_setting.gedcom_id', '=', 'media.m_file')
170                            ->where('setting_name', '=', 'MEDIA_DIRECTORY');
171                    })
172                    ->where('multimedia_file_refn', 'NOT LIKE', 'http://%')
173                    ->where('multimedia_file_refn', 'NOT LIKE', 'https://%')
174                    ->select([
175                        'media.*',
176                        'multimedia_file_refn',
177                        'descriptive_title',
178                        new Expression("COALESCE(setting_value, 'media/') AS media_folder"),
179                    ]);
180
181                $query->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%');
182
183                if ($subfolders === 'exclude') {
184                    $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%');
185                }
186
187                return $this->datatables_service->handleQuery($request, $query, $search_columns, $sort_columns, $callback);
188
189            case 'external':
190                $query = DB::table('media_file')
191                    ->join('media', static function (JoinClause $join): void {
192                        $join
193                            ->on('media.m_file', '=', 'media_file.m_file')
194                            ->on('media.m_id', '=', 'media_file.m_id');
195                    })
196                    ->where(static function (Builder $query): void {
197                        $query
198                            ->where('multimedia_file_refn', 'LIKE', 'http://%')
199                            ->orWhere('multimedia_file_refn', 'LIKE', 'https://%');
200                    })
201                    ->select([
202                        'media.*',
203                        'multimedia_file_refn',
204                        'descriptive_title',
205                        new Expression("'' AS media_folder"),
206                    ]);
207
208                return $this->datatables_service->handleQuery($request, $query, $search_columns, $sort_columns, $callback);
209
210            case 'unused':
211                // Which trees use which media folder?
212                $media_trees = DB::table('gedcom')
213                    ->join('gedcom_setting', 'gedcom_setting.gedcom_id', '=', 'gedcom.gedcom_id')
214                    ->where('setting_name', '=', 'MEDIA_DIRECTORY')
215                    ->where('gedcom.gedcom_id', '>', 0)
216                    ->pluck('setting_value', 'gedcom_name');
217
218                $disk_files = $this->media_file_service->allFilesOnDisk($data_filesystem, $media_folder, $subfolders === 'include');
219                $db_files   = $this->media_file_service->allFilesInDatabase($media_folder, $subfolders === 'include');
220
221                // All unused files
222                $unused_files = $disk_files->diff($db_files)
223                    ->map(static function (string $file): array {
224                        return (array) $file;
225                    });
226
227                $search_columns = [0];
228                $sort_columns   = [0 => 0];
229
230                $callback = function (array $row) use ($data_filesystem, $media_trees): array {
231                    try {
232                        $mime_type = $data_filesystem->mimeType($row[0]) ?: Mime::DEFAULT_TYPE;
233                    } catch (FilesystemException | UnableToRetrieveMetadata) {
234                        $mime_type = Mime::DEFAULT_TYPE;
235                    }
236
237
238                    if (str_starts_with($mime_type, 'image/')) {
239                        $url = route(AdminMediaFileThumbnail::class, ['path' => $row[0]]);
240                        $img = '<img src="' . e($url) . '">';
241                    } else {
242                        $img = view('icons/mime', ['type' => $mime_type]);
243                    }
244
245                    $url = route(AdminMediaFileDownload::class, ['path' => $row[0]]);
246                    $img = '<a href="' . e($url) . '">' . $img . '</a>';
247
248                    // Form to create new media object in each tree
249                    $create_form = '';
250                    foreach ($media_trees as $media_tree => $media_directory) {
251                        if (str_starts_with($row[0], $media_directory)) {
252                            $tmp = substr($row[0], strlen($media_directory));
253                            $create_form .=
254                                '<p><a href="#" data-bs-toggle="modal" data-bs-backdrop="static" data-bs-target="#modal-create-media-from-file" data-file="' . e($tmp) . '" data-url="' . e(route(CreateMediaObjectFromFile::class, ['tree' => $media_tree])) . '" onclick="document.getElementById(\'modal-create-media-from-file-form\').action=this.dataset.url; document.getElementById(\'file\').value=this.dataset.file;">' . I18N::translate('Create') . '</a> — ' . e($media_tree) . '<p>';
255                        }
256                    }
257
258                    $delete_link = '<p><a data-wt-confirm="' . I18N::translate('Are you sure you want to delete “%s”?', e($row[0])) . '" data-wt-post-url="' . e(route(DeletePath::class, [
259                            'path' => $row[0],
260                        ])) . '" href="#">' . I18N::translate('Delete') . '</a></p>';
261
262                    return [
263                        $this->mediaFileInfo($data_filesystem, $row[0]) . $delete_link,
264                        $img,
265                        $create_form,
266                    ];
267                };
268
269                return $this->datatables_service->handleCollection($request, $unused_files, $search_columns, $sort_columns, $callback);
270
271            default:
272                throw new HttpNotFoundException();
273        }
274    }
275
276    /**
277     * Generate some useful information and links about a media object.
278     *
279     * @param Media $media
280     *
281     * @return string HTML
282     */
283    private function mediaObjectInfo(Media $media): string
284    {
285        $element = Registry::elementFactory()->make('NOTE:CONC');
286        $html    = '<a href="' . e($media->url()) . '" title="' . e($media->tree()->title()) . '">' . $media->fullName() . '</a>';
287
288        if ($this->tree_service->all()->count() > 1) {
289            $html .= ' — ' . e($media->tree()->title());
290        }
291
292        $html .= $element->value($media->getNote(), $media->tree());
293
294        $linked = [];
295
296        foreach ($this->linked_record_service->linkedIndividuals($media) as $link) {
297            $linked[] = view('icons/individual') . '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
298        }
299
300        foreach ($this->linked_record_service->linkedFamilies($media) as $link) {
301            $linked[] = view('icons/family') . '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
302        }
303
304        foreach ($this->linked_record_service->linkedSources($media) as $link) {
305            $linked[] = view('icons/source') . '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
306        }
307
308        foreach ($this->linked_record_service->linkedNotes($media) as $link) {
309            $linked[] = view('icons/note') . '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
310        }
311
312        foreach ($this->linked_record_service->linkedRepositories($media) as $link) {
313            $linked[] = view('icons/media') . '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
314        }
315
316        foreach ($this->linked_record_service->linkedMedia($media) as $link) {
317            $linked[] = view('icons/location') . '<a href="' . e($link->url()) . '">' . $link->fullName() . '</a>';
318        }
319
320        if ($linked !== []) {
321            $html .= '<ul class="list-unstyled">';
322            foreach ($linked as $link) {
323                $html .= '<li>' . $link . '</li>';
324            }
325            $html .= '</ul>';
326        } else {
327            $html .= '<div class="alert alert-danger">' . I18N::translate('There are no links to this media object.') . '</div>';
328        }
329
330        return $html;
331    }
332
333    /**
334     * Generate some useful information and links about a media file.
335     *
336     * @param FilesystemOperator $data_filesystem
337     * @param string             $file
338     *
339     * @return string
340     */
341    private function mediaFileInfo(FilesystemOperator $data_filesystem, string $file): string
342    {
343        $html = '<dl>';
344        $html .= '<dt>' . I18N::translate('Filename') . '</dt>';
345        $html .= '<dd>' . e($file) . '</dd>';
346
347        try {
348            $file_exists = $data_filesystem->fileExists($file);
349        } catch (FilesystemException | UnableToCheckFileExistence) {
350            $file_exists = false;
351        }
352
353        if ($file_exists) {
354            try {
355                $size = $data_filesystem->fileSize($file);
356            } catch (FilesystemException | UnableToRetrieveMetadata) {
357                $size = 0;
358            }
359            $size = intdiv($size + 1023, 1024); // Round up to next KB
360            /* I18N: size of file in KB */
361            $size = I18N::translate('%s KB', I18N::number($size));
362            $html .= '<dt>' . I18N::translate('File size') . '</dt>';
363            $html .= '<dd>' . $size . '</dd>';
364
365            try {
366                // This will work for local filesystems.  For remote filesystems, we will
367                // need to copy the file locally to work out the image size.
368                $imgsize = getimagesizefromstring($data_filesystem->read($file));
369                $html .= '<dt>' . I18N::translate('Image dimensions') . '</dt>';
370                /* I18N: image dimensions, width × height */
371                $html .= '<dd>' . I18N::translate('%1$s × %2$s pixels', I18N::number($imgsize['0']), I18N::number($imgsize['1'])) . '</dd>';
372            } catch (FilesystemException | UnableToReadFile | Throwable) {
373                // Not an image, or not a valid image?
374            }
375        }
376
377        $html .= '</dl>';
378
379        return $html;
380    }
381}
382