1d4265d07SGreg Roach<?php 2d4265d07SGreg Roach 3d4265d07SGreg Roach/** 4d4265d07SGreg Roach * webtrees: online genealogy 5d4265d07SGreg Roach * Copyright (C) 2019 webtrees development team 6d4265d07SGreg Roach * This program is free software: you can redistribute it and/or modify 7d4265d07SGreg Roach * it under the terms of the GNU General Public License as published by 8d4265d07SGreg Roach * the Free Software Foundation, either version 3 of the License, or 9d4265d07SGreg Roach * (at your option) any later version. 10d4265d07SGreg Roach * This program is distributed in the hope that it will be useful, 11d4265d07SGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of 12d4265d07SGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13d4265d07SGreg Roach * GNU General Public License for more details. 14d4265d07SGreg Roach * You should have received a copy of the GNU General Public License 15d4265d07SGreg Roach * along with this program. If not, see <http://www.gnu.org/licenses/>. 16d4265d07SGreg Roach */ 17d4265d07SGreg Roach 18d4265d07SGreg Roachdeclare(strict_types=1); 19d4265d07SGreg Roach 20d4265d07SGreg Roachnamespace Fisharebest\Webtrees\Services; 21d4265d07SGreg Roach 22d4265d07SGreg Roachuse Fisharebest\Webtrees\FlashMessages; 23d4265d07SGreg Roachuse Fisharebest\Webtrees\GedcomTag; 24d4265d07SGreg Roachuse Fisharebest\Webtrees\I18N; 25d4265d07SGreg Roachuse Fisharebest\Webtrees\Tree; 26d4265d07SGreg Roachuse Illuminate\Database\Capsule\Manager as DB; 2713aa75d8SGreg Roachuse Illuminate\Database\Query\Expression; 2813aa75d8SGreg Roachuse Illuminate\Support\Collection; 29d4265d07SGreg Roachuse InvalidArgumentException; 30a04bb9a2SGreg Roachuse League\Flysystem\FilesystemInterface; 31d4265d07SGreg Roachuse Psr\Http\Message\ServerRequestInterface; 32d4265d07SGreg Roachuse Psr\Http\Message\UploadedFileInterface; 33d4265d07SGreg Roachuse RuntimeException; 34d4265d07SGreg Roach 35d4265d07SGreg Roachuse function array_combine; 36d4265d07SGreg Roachuse function array_diff; 37d4265d07SGreg Roachuse function array_filter; 38d4265d07SGreg Roachuse function array_map; 39d4265d07SGreg Roachuse function assert; 4013aa75d8SGreg Roachuse function dirname; 41d501c45dSGreg Roachuse function ini_get; 42d4265d07SGreg Roachuse function intdiv; 43d501c45dSGreg Roachuse function min; 44d4265d07SGreg Roachuse function pathinfo; 4545fc2659SGreg Roachuse function preg_replace; 46d4265d07SGreg Roachuse function sha1; 47d4265d07SGreg Roachuse function sort; 48d4265d07SGreg Roachuse function str_replace; 49d4265d07SGreg Roachuse function strpos; 50d4265d07SGreg Roachuse function strtolower; 5145fc2659SGreg Roachuse function strtr; 52d501c45dSGreg Roachuse function substr; 53d4265d07SGreg Roachuse function trim; 54d4265d07SGreg Roach 55d4265d07SGreg Roachuse const PATHINFO_EXTENSION; 56d4265d07SGreg Roachuse const UPLOAD_ERR_OK; 57d4265d07SGreg Roach 58d4265d07SGreg Roach/** 59d4265d07SGreg Roach * Managing media files. 60d4265d07SGreg Roach */ 61d4265d07SGreg Roachclass MediaFileService 62d4265d07SGreg Roach{ 63d4265d07SGreg Roach public const EDIT_RESTRICTIONS = [ 64d4265d07SGreg Roach 'locked', 65d4265d07SGreg Roach ]; 66d4265d07SGreg Roach 67d4265d07SGreg Roach public const PRIVACY_RESTRICTIONS = [ 68d4265d07SGreg Roach 'none', 69d4265d07SGreg Roach 'privacy', 70d4265d07SGreg Roach 'confidential', 71d4265d07SGreg Roach ]; 72d4265d07SGreg Roach 7345fc2659SGreg Roach public const EXTENSION_TO_FORM = [ 7445fc2659SGreg Roach 'jpg' => 'jpeg', 7545fc2659SGreg Roach 'tif' => 'tiff', 7645fc2659SGreg Roach ]; 7745fc2659SGreg Roach 78d4265d07SGreg Roach /** 79d4265d07SGreg Roach * What is the largest file a user may upload? 80d4265d07SGreg Roach */ 81d4265d07SGreg Roach public function maxUploadFilesize(): string 82d4265d07SGreg Roach { 83d501c45dSGreg Roach $sizePostMax = $this->parseIniFileSize(ini_get('post_max_size')); 84d501c45dSGreg Roach $sizeUploadMax = $this->parseIniFileSize(ini_get('upload_max_filesize')); 85d501c45dSGreg Roach 86d501c45dSGreg Roach $bytes = min($sizePostMax, $sizeUploadMax); 87d4265d07SGreg Roach $kb = intdiv($bytes + 1023, 1024); 88d4265d07SGreg Roach 89d4265d07SGreg Roach return I18N::translate('%s KB', I18N::number($kb)); 90d4265d07SGreg Roach } 91d4265d07SGreg Roach 92d4265d07SGreg Roach /** 93d501c45dSGreg Roach * Returns the given size from an ini value in bytes. 94d501c45dSGreg Roach * 95284014f8SGreg Roach * @param string $size 96d501c45dSGreg Roach * 97d501c45dSGreg Roach * @return int 98d501c45dSGreg Roach */ 99284014f8SGreg Roach private function parseIniFileSize(string $size): int 100d501c45dSGreg Roach { 101d501c45dSGreg Roach $number = (int) $size; 102d501c45dSGreg Roach 103d501c45dSGreg Roach switch (substr($size, -1)) { 104d501c45dSGreg Roach case 't': 105d501c45dSGreg Roach case 'T': 106d501c45dSGreg Roach return $number * 1024 ** 4; 107d501c45dSGreg Roach case 'g': 108d501c45dSGreg Roach case 'G': 109d501c45dSGreg Roach return $number * 1024 ** 3; 110d501c45dSGreg Roach case 'm': 111d501c45dSGreg Roach case 'M': 112d501c45dSGreg Roach return $number * 1024 ** 2; 113d501c45dSGreg Roach case 'k': 114d501c45dSGreg Roach case 'K': 115d501c45dSGreg Roach return $number * 1024; 116d501c45dSGreg Roach default: 117d501c45dSGreg Roach return $number; 118d501c45dSGreg Roach } 119d501c45dSGreg Roach } 120d501c45dSGreg Roach 121d501c45dSGreg Roach /** 122d4265d07SGreg Roach * A list of key/value options for media types. 123d4265d07SGreg Roach * 124d4265d07SGreg Roach * @param string $current 125d4265d07SGreg Roach * 126d4265d07SGreg Roach * @return array 127d4265d07SGreg Roach */ 128d4265d07SGreg Roach public function mediaTypes($current = ''): array 129d4265d07SGreg Roach { 130d4265d07SGreg Roach $media_types = GedcomTag::getFileFormTypes(); 131d4265d07SGreg Roach 132d4265d07SGreg Roach $media_types = ['' => ''] + [$current => $current] + $media_types; 133d4265d07SGreg Roach 134d4265d07SGreg Roach return $media_types; 135d4265d07SGreg Roach } 136d4265d07SGreg Roach 137d4265d07SGreg Roach /** 138d4265d07SGreg Roach * A list of media files not already linked to a media object. 139d4265d07SGreg Roach * 140d4265d07SGreg Roach * @param Tree $tree 141a04bb9a2SGreg Roach * @param FilesystemInterface $data_filesystem 142d4265d07SGreg Roach * 143d4265d07SGreg Roach * @return array 144d4265d07SGreg Roach */ 145a04bb9a2SGreg Roach public function unusedFiles(Tree $tree, FilesystemInterface $data_filesystem): array 146d4265d07SGreg Roach { 147d4265d07SGreg Roach $used_files = DB::table('media_file') 148d4265d07SGreg Roach ->where('m_file', '=', $tree->id()) 149d4265d07SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'http://%') 150d4265d07SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'https://%') 151d4265d07SGreg Roach ->pluck('multimedia_file_refn') 152d4265d07SGreg Roach ->all(); 153d4265d07SGreg Roach 154a04bb9a2SGreg Roach $disk_files = $tree->mediaFilesystem($data_filesystem)->listContents('', true); 155d4265d07SGreg Roach 156d4265d07SGreg Roach $disk_files = array_filter($disk_files, static function (array $item) { 157d4265d07SGreg Roach // Older versions of webtrees used a couple of special folders. 158d4265d07SGreg Roach return 159d4265d07SGreg Roach $item['type'] === 'file' && 160d4265d07SGreg Roach strpos($item['path'], '/thumbs/') === false && 161d4265d07SGreg Roach strpos($item['path'], '/watermarks/') === false; 162d4265d07SGreg Roach }); 163d4265d07SGreg Roach 164d4265d07SGreg Roach $disk_files = array_map(static function (array $item): string { 165d4265d07SGreg Roach return $item['path']; 166d4265d07SGreg Roach }, $disk_files); 167d4265d07SGreg Roach 168d4265d07SGreg Roach $unused_files = array_diff($disk_files, $used_files); 169d4265d07SGreg Roach 170d4265d07SGreg Roach sort($unused_files); 171d4265d07SGreg Roach 172d4265d07SGreg Roach return array_combine($unused_files, $unused_files); 173d4265d07SGreg Roach } 174d4265d07SGreg Roach 175d4265d07SGreg Roach /** 176d4265d07SGreg Roach * Store an uploaded file (or URL), either to be added to a media object 177d4265d07SGreg Roach * or to create a media object. 178d4265d07SGreg Roach * 179d4265d07SGreg Roach * @param ServerRequestInterface $request 180d4265d07SGreg Roach * 181d4265d07SGreg Roach * @return string The value to be stored in the 'FILE' field of the media object. 182d4265d07SGreg Roach */ 183d4265d07SGreg Roach public function uploadFile(ServerRequestInterface $request): string 184d4265d07SGreg Roach { 185d4265d07SGreg Roach $tree = $request->getAttribute('tree'); 186d4265d07SGreg Roach assert($tree instanceof Tree); 187d4265d07SGreg Roach 188a04bb9a2SGreg Roach $data_filesystem = $request->getAttribute('filesystem.data'); 189a04bb9a2SGreg Roach assert($data_filesystem instanceof FilesystemInterface); 190a04bb9a2SGreg Roach 191b46c87bdSGreg Roach $params = (array) $request->getParsedBody(); 192d4265d07SGreg Roach $file_location = $params['file_location']; 193d4265d07SGreg Roach 194d4265d07SGreg Roach switch ($file_location) { 195d4265d07SGreg Roach case 'url': 196d4265d07SGreg Roach $remote = $params['remote']; 197d4265d07SGreg Roach 198d4265d07SGreg Roach if (strpos($remote, '://') !== false) { 199d4265d07SGreg Roach return $remote; 200d4265d07SGreg Roach } 201d4265d07SGreg Roach 202d4265d07SGreg Roach return ''; 203d4265d07SGreg Roach 204d4265d07SGreg Roach case 'unused': 205d4265d07SGreg Roach $unused = $params['unused']; 206d4265d07SGreg Roach 207a04bb9a2SGreg Roach if ($tree->mediaFilesystem($data_filesystem)->has($unused)) { 208d4265d07SGreg Roach return $unused; 209d4265d07SGreg Roach } 210d4265d07SGreg Roach 211d4265d07SGreg Roach return ''; 212d4265d07SGreg Roach 213d4265d07SGreg Roach case 'upload': 214d4265d07SGreg Roach default: 215d4265d07SGreg Roach $folder = $params['folder']; 216d4265d07SGreg Roach $auto = $params['auto']; 217d4265d07SGreg Roach $new_file = $params['new_file']; 218d4265d07SGreg Roach 219d4265d07SGreg Roach /** @var UploadedFileInterface|null $uploaded_file */ 220d4265d07SGreg Roach $uploaded_file = $request->getUploadedFiles()['file']; 221d4265d07SGreg Roach if ($uploaded_file === null || $uploaded_file->getError() !== UPLOAD_ERR_OK) { 222d4265d07SGreg Roach return ''; 223d4265d07SGreg Roach } 224d4265d07SGreg Roach 225d4265d07SGreg Roach // The filename 226d4265d07SGreg Roach $new_file = str_replace('\\', '/', $new_file); 227d4265d07SGreg Roach if ($new_file !== '' && strpos($new_file, '/') === false) { 228d4265d07SGreg Roach $file = $new_file; 229d4265d07SGreg Roach } else { 230d4265d07SGreg Roach $file = $uploaded_file->getClientFilename(); 231d4265d07SGreg Roach } 232d4265d07SGreg Roach 233d4265d07SGreg Roach // The folder 234d4265d07SGreg Roach $folder = str_replace('\\', '/', $folder); 235d4265d07SGreg Roach $folder = trim($folder, '/'); 236d4265d07SGreg Roach if ($folder !== '') { 237d4265d07SGreg Roach $folder .= '/'; 238d4265d07SGreg Roach } 239d4265d07SGreg Roach 240d4265d07SGreg Roach // Generate a unique name for the file? 241a04bb9a2SGreg Roach if ($auto === '1' || $tree->mediaFilesystem($data_filesystem)->has($folder . $file)) { 242d4265d07SGreg Roach $folder = ''; 243d4265d07SGreg Roach $extension = pathinfo($uploaded_file->getClientFilename(), PATHINFO_EXTENSION); 244d4265d07SGreg Roach $file = sha1((string) $uploaded_file->getStream()) . '.' . $extension; 245d4265d07SGreg Roach } 246d4265d07SGreg Roach 247d4265d07SGreg Roach try { 248*9ddec9bcSGreg Roach $tree->mediaFilesystem($data_filesystem)->putStream($folder . $file, $uploaded_file->getStream()->detach()); 249d4265d07SGreg Roach 250d4265d07SGreg Roach return $folder . $file; 251d4265d07SGreg Roach } catch (RuntimeException | InvalidArgumentException $ex) { 252d4265d07SGreg Roach FlashMessages::addMessage(I18N::translate('There was an error uploading your file.')); 253d4265d07SGreg Roach 254d4265d07SGreg Roach return ''; 255d4265d07SGreg Roach } 256d4265d07SGreg Roach } 257d4265d07SGreg Roach } 258d4265d07SGreg Roach 259d4265d07SGreg Roach /** 260d4265d07SGreg Roach * Convert the media file attributes into GEDCOM format. 261d4265d07SGreg Roach * 262d4265d07SGreg Roach * @param string $file 263d4265d07SGreg Roach * @param string $type 264d4265d07SGreg Roach * @param string $title 26545fc2659SGreg Roach * @param string $note 266d4265d07SGreg Roach * 267d4265d07SGreg Roach * @return string 268d4265d07SGreg Roach */ 26945fc2659SGreg Roach public function createMediaFileGedcom(string $file, string $type, string $title, string $note): string 270d4265d07SGreg Roach { 27145fc2659SGreg Roach // Tidy whitespace 27245fc2659SGreg Roach $type = trim(preg_replace('/\s+/', ' ', $type)); 27345fc2659SGreg Roach $title = trim(preg_replace('/\s+/', ' ', $title)); 274d4265d07SGreg Roach 275d4265d07SGreg Roach $gedcom = '1 FILE ' . $file; 27645fc2659SGreg Roach 27745fc2659SGreg Roach $format = strtolower(pathinfo($file, PATHINFO_EXTENSION)); 27845fc2659SGreg Roach $format = self::EXTENSION_TO_FORM[$format] ?? $format; 27945fc2659SGreg Roach 28045fc2659SGreg Roach if ($format !== '') { 28145fc2659SGreg Roach $gedcom .= "\n2 FORM " . $format; 28245fc2659SGreg Roach } elseif ($type !== '') { 28345fc2659SGreg Roach $gedcom .= "\n2 FORM"; 284d4265d07SGreg Roach } 28545fc2659SGreg Roach 28645fc2659SGreg Roach if ($type !== '') { 28745fc2659SGreg Roach $gedcom .= "\n3 TYPE " . $type; 28845fc2659SGreg Roach } 28945fc2659SGreg Roach 290d4265d07SGreg Roach if ($title !== '') { 291d4265d07SGreg Roach $gedcom .= "\n2 TITL " . $title; 292d4265d07SGreg Roach } 293d4265d07SGreg Roach 29445fc2659SGreg Roach if ($note !== '') { 29545fc2659SGreg Roach // Convert HTML line endings to GEDCOM continuations 29645fc2659SGreg Roach $gedcom .= "\n1 NOTE " . strtr($note, ["\r\n" => "\n2 CONT "]); 29745fc2659SGreg Roach } 29845fc2659SGreg Roach 299d4265d07SGreg Roach return $gedcom; 300d4265d07SGreg Roach } 30113aa75d8SGreg Roach 30213aa75d8SGreg Roach /** 30313aa75d8SGreg Roach * Fetch a list of all files on disk (in folders used by any tree). 30413aa75d8SGreg Roach * 30513aa75d8SGreg Roach * @param FilesystemInterface $data_filesystem Fileystem to search 30613aa75d8SGreg Roach * @param string $media_folder Root folder 30713aa75d8SGreg Roach * @param bool $subfolders Include subfolders 30813aa75d8SGreg Roach * 309b5c8fd7eSGreg Roach * @return Collection<string> 31013aa75d8SGreg Roach */ 31113aa75d8SGreg Roach public function allFilesOnDisk(FilesystemInterface $data_filesystem, string $media_folder, bool $subfolders): Collection 31213aa75d8SGreg Roach { 31313aa75d8SGreg Roach $array = $data_filesystem->listContents($media_folder, $subfolders); 31413aa75d8SGreg Roach 31513aa75d8SGreg Roach return Collection::make($array) 31613aa75d8SGreg Roach ->filter(static function (array $metadata): bool { 31713aa75d8SGreg Roach return 31813aa75d8SGreg Roach $metadata['type'] === 'file' && 31913aa75d8SGreg Roach strpos($metadata['path'], '/thumbs/') === false && 32013aa75d8SGreg Roach strpos($metadata['path'], '/watermark/') === false; 32113aa75d8SGreg Roach }) 32213aa75d8SGreg Roach ->map(static function (array $metadata): string { 32313aa75d8SGreg Roach return $metadata['path']; 32413aa75d8SGreg Roach }); 32513aa75d8SGreg Roach } 32613aa75d8SGreg Roach 32713aa75d8SGreg Roach /** 32813aa75d8SGreg Roach * Fetch a list of all files on in the database. 32913aa75d8SGreg Roach * 33013aa75d8SGreg Roach * @param string $media_folder Root folder 33113aa75d8SGreg Roach * @param bool $subfolders Include subfolders 33213aa75d8SGreg Roach * 333b5c8fd7eSGreg Roach * @return Collection<string> 33413aa75d8SGreg Roach */ 33513aa75d8SGreg Roach public function allFilesInDatabase(string $media_folder, bool $subfolders): Collection 33613aa75d8SGreg Roach { 33713aa75d8SGreg Roach $query = DB::table('media_file') 33813aa75d8SGreg Roach ->join('gedcom_setting', 'gedcom_id', '=', 'm_file') 33913aa75d8SGreg Roach ->where('setting_name', '=', 'MEDIA_DIRECTORY') 34013aa75d8SGreg Roach //->where('multimedia_file_refn', 'LIKE', '%/%') 34113aa75d8SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'http://%') 34213aa75d8SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'https://%') 34313aa75d8SGreg Roach ->where(new Expression('setting_value || multimedia_file_refn'), 'LIKE', $media_folder . '%') 34413aa75d8SGreg Roach ->select(new Expression('setting_value || multimedia_file_refn AS path')) 34513aa75d8SGreg Roach ->orderBy(new Expression('setting_value || multimedia_file_refn')); 34613aa75d8SGreg Roach 34713aa75d8SGreg Roach if (!$subfolders) { 34813aa75d8SGreg Roach $query->where(new Expression('setting_value || multimedia_file_refn'), 'NOT LIKE', $media_folder . '%/%'); 34913aa75d8SGreg Roach } 35013aa75d8SGreg Roach 35113aa75d8SGreg Roach return $query->pluck('path'); 35213aa75d8SGreg Roach } 35313aa75d8SGreg Roach 35413aa75d8SGreg Roach /** 35513aa75d8SGreg Roach * Generate a list of all folders in either the database or the filesystem. 35613aa75d8SGreg Roach * 35713aa75d8SGreg Roach * @param FilesystemInterface $data_filesystem 35813aa75d8SGreg Roach * 359b5c8fd7eSGreg Roach * @return Collection<string,string> 36013aa75d8SGreg Roach */ 36113aa75d8SGreg Roach public function allMediaFolders(FilesystemInterface $data_filesystem): Collection 36213aa75d8SGreg Roach { 36313aa75d8SGreg Roach $db_folders = DB::table('media_file') 36413aa75d8SGreg Roach ->join('gedcom_setting', 'gedcom_id', '=', 'm_file') 36513aa75d8SGreg Roach ->where('setting_name', '=', 'MEDIA_DIRECTORY') 36613aa75d8SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'http://%') 36713aa75d8SGreg Roach ->where('multimedia_file_refn', 'NOT LIKE', 'https://%') 36813aa75d8SGreg Roach ->select(new Expression('setting_value || multimedia_file_refn AS path')) 36913aa75d8SGreg Roach ->pluck('path') 37013aa75d8SGreg Roach ->map(static function (string $path): string { 37113aa75d8SGreg Roach return dirname($path) . '/'; 37213aa75d8SGreg Roach }); 37313aa75d8SGreg Roach 37413aa75d8SGreg Roach $media_roots = DB::table('gedcom_setting') 37513aa75d8SGreg Roach ->where('setting_name', '=', 'MEDIA_DIRECTORY') 3765d32b84fSGreg Roach ->where('gedcom_id', '>', '0') 37713aa75d8SGreg Roach ->pluck('setting_value') 3788c627a69SGreg Roach ->uniqueStrict(); 37913aa75d8SGreg Roach 38013aa75d8SGreg Roach $disk_folders = new Collection($media_roots); 38113aa75d8SGreg Roach 38213aa75d8SGreg Roach foreach ($media_roots as $media_folder) { 38313aa75d8SGreg Roach $tmp = Collection::make($data_filesystem->listContents($media_folder, true)) 38413aa75d8SGreg Roach ->filter(static function (array $metadata) { 38513aa75d8SGreg Roach return $metadata['type'] === 'dir'; 38613aa75d8SGreg Roach }) 38713aa75d8SGreg Roach ->map(static function (array $metadata): string { 38813aa75d8SGreg Roach return $metadata['path'] . '/'; 38913aa75d8SGreg Roach }) 39013aa75d8SGreg Roach ->filter(static function (string $dir): bool { 39113aa75d8SGreg Roach return strpos($dir, '/thumbs/') === false && strpos($dir, 'watermarks') === false; 39213aa75d8SGreg Roach }); 39313aa75d8SGreg Roach 39413aa75d8SGreg Roach $disk_folders = $disk_folders->concat($tmp); 39513aa75d8SGreg Roach } 39613aa75d8SGreg Roach 39713aa75d8SGreg Roach return $disk_folders->concat($db_folders) 3988c627a69SGreg Roach ->uniqueStrict() 39913aa75d8SGreg Roach ->mapWithKeys(static function (string $folder): array { 40013aa75d8SGreg Roach return [$folder => $folder]; 40113aa75d8SGreg Roach }); 40213aa75d8SGreg Roach } 403d4265d07SGreg Roach} 404