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