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