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