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