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