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