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