xref: /webtrees/app/Media.php (revision a99c693842ed6ceb1157d751f995cb80ed1195c8)
1<?php
2/**
3 * webtrees: online genealogy
4 * Copyright (C) 2017 webtrees development team
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License
14 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15 */
16namespace Fisharebest\Webtrees;
17
18use Fisharebest\Webtrees\Functions\FunctionsPrintFacts;
19use League\Glide\Urls\UrlBuilderFactory;
20
21/**
22 * A GEDCOM media (OBJE) object.
23 */
24class Media extends GedcomRecord {
25	const RECORD_TYPE = 'OBJE';
26	const URL_PREFIX  = 'mediaviewer.php?mid=';
27
28	/**
29	 * Each object type may have its own special rules, and re-implement this function.
30	 *
31	 * @param int $access_level
32	 *
33	 * @return bool
34	 */
35	protected function canShowByType($access_level) {
36		// Hide media objects if they are attached to private records
37		$linked_ids = Database::prepare(
38			"SELECT l_from FROM `##link` WHERE l_to = ? AND l_file = ?"
39		)->execute([
40			$this->xref, $this->tree->getTreeId(),
41		])->fetchOneColumn();
42		foreach ($linked_ids as $linked_id) {
43			$linked_record = GedcomRecord::getInstance($linked_id, $this->tree);
44			if ($linked_record && !$linked_record->canShow($access_level)) {
45				return false;
46			}
47		}
48
49		// ... otherwise apply default behaviour
50		return parent::canShowByType($access_level);
51	}
52
53	/**
54	 * Fetch data from the database
55	 *
56	 * @param string $xref
57	 * @param int    $tree_id
58	 *
59	 * @return null|string
60	 */
61	protected static function fetchGedcomRecord($xref, $tree_id) {
62		return Database::prepare(
63			"SELECT m_gedcom FROM `##media` WHERE m_id = :xref AND m_file = :tree_id"
64		)->execute([
65			'xref'    => $xref,
66			'tree_id' => $tree_id,
67		])->fetchOne();
68	}
69
70	/**
71	 * Get the media files for this media object
72	 *
73	 * @return MediaFile[]
74	 */
75	public function mediaFiles(): array {
76		$media_files = [];
77
78		foreach ($this->getFacts('FILE') as $fact) {
79			$media_files[] = new MediaFile($fact->getGedcom(), $this);
80		}
81
82		return $media_files;
83	}
84
85	/**
86	 * The prefered media file to be shown for this media object
87	 *
88	 * @return MediaFile|null
89	 */
90	public function mediaFile() {
91		foreach ($this->mediaFiles() as $media_file) {
92			if (in_array($media_file->extension(), ['jpeg', 'png', 'gif'])) {
93				return $media_file;
94			}
95		}
96
97		return null;
98	}
99
100	/**
101	 * Get the first note attached to this media object
102	 *
103	 * @return null|string
104	 */
105	public function getNote() {
106		$note = $this->getFirstFact('NOTE');
107		if ($note) {
108			$text = $note->getValue();
109			if (preg_match('/^@' . WT_REGEX_XREF . '@$/', $text)) {
110				$text = $note->getTarget()->getNote();
111			}
112
113			return $text;
114		} else {
115			return '';
116		}
117	}
118
119	/**
120	 * Get the main media filename
121	 *
122	 * @return string
123	 */
124	public function getFilename() {
125		return $this->file;
126	}
127
128	/**
129	 * Get the media's title (name)
130	 *
131	 * @return string
132	 */
133	public function getTitle() {
134		return $this->title;
135	}
136
137	/**
138	 * Get the filename on the server - for those (very few!) functions which actually
139	 * need the filename, such as mediafirewall.php and the PDF reports.
140	 *
141	 * @return string
142	 */
143	public function getServerFilename() {
144		$MEDIA_DIRECTORY = $this->tree->getPreference('MEDIA_DIRECTORY');
145
146		if ($this->isExternal() || !$this->file) {
147			// External image, or (in the case of corrupt GEDCOM data) no image at all
148			return $this->file;
149		} else {
150			// Main image
151			return WT_DATA_DIR . $MEDIA_DIRECTORY . $this->file;
152		}
153	}
154
155	/**
156	 * check if the file exists on this server
157	 *
158	 * @return bool
159	 */
160	public function fileExists() {
161		return file_exists($this->getServerFilename());
162	}
163
164	/**
165	 * Determine if the file is an external url
166	 *
167	 * @return bool
168	 */
169	public function isExternal() {
170		foreach ($this->mediaFiles() as $media_file) {
171			if (strpos($media_file->filename(), '://') !== false) {
172				return true;
173			}
174		}
175
176		return false;
177	}
178
179	/**
180	 * get the media file size in KB
181	 *
182	 * @return string
183	 */
184	public function getFilesize() {
185		$size = $this->getFilesizeraw();
186		// Round up to the nearest KB.
187		$size = (int) (($size + 1023) / 1024);
188
189		return /* I18N: size of file in KB */
190			I18N::translate('%s KB', I18N::number($size));
191	}
192
193	/**
194	 * get the media file size, unformatted
195	 *
196	 * @return int
197	 */
198	public function getFilesizeraw() {
199		try {
200			return filesize($this->getServerFilename());
201		} catch (\ErrorException $ex) {
202			DebugBar::addThrowable($ex);
203
204			return 0;
205		}
206	}
207
208	/**
209	 * Deprecated? This does not need to be a function here.
210	 *
211	 * @return string
212	 */
213	public function getMediaType() {
214		if (preg_match('/\n\d TYPE (.+)/', $this->gedcom, $match)) {
215			return strtolower($match[1]);
216		} else {
217			return '';
218		}
219	}
220
221	/**
222	 * get image properties
223	 *
224	 * @return array
225	 */
226	public function getImageAttributes() {
227		$imgsize = [];
228		if ($this->fileExists()) {
229			try {
230				$imgsize = getimagesize($this->getServerFilename());
231				if (is_array($imgsize) && !empty($imgsize['0'])) {
232					// this is an image
233					$imageTypes     = ['', 'GIF', 'JPG', 'PNG', 'SWF', 'PSD', 'BMP', 'TIFF', 'TIFF', 'JPC', 'JP2', 'JPX', 'JB2', 'SWC', 'IFF', 'WBMP', 'XBM'];
234					$imgsize['ext'] = $imageTypes[0 + $imgsize[2]];
235					// this is for display purposes, always show non-adjusted info
236					$imgsize['WxH'] = /* I18N: image dimensions, width × height */
237						I18N::translate('%1$s × %2$s pixels', I18N::number($imgsize['0']), I18N::number($imgsize['1']));
238				}
239			} catch (\ErrorException $ex) {
240				DebugBar::addThrowable($ex);
241
242				// Not an image, or not a valid image?
243				$imgsize = false;
244			}
245		}
246
247		if (!is_array($imgsize) || empty($imgsize['0'])) {
248			// this is not an image, OR the file doesn’t exist OR it is a url
249			$imgsize[0]      = 0;
250			$imgsize[1]      = 0;
251			$imgsize['ext']  = '';
252			$imgsize['mime'] = '';
253			$imgsize['WxH']  = '';
254		}
255
256		if (empty($imgsize['mime'])) {
257			// this is not an image, OR the file doesn’t exist OR it is a url
258			// set file type equal to the file extension - can’t use parse_url because this may not be a full url
259			$exp            = explode('?', $this->file);
260			$imgsize['ext'] = strtoupper(pathinfo($exp[0], PATHINFO_EXTENSION));
261			// all mimetypes we wish to serve with the media firewall must be added to this array.
262			$mime = [
263				'DOC' => 'application/msword',
264				'MOV' => 'video/quicktime',
265				'MP3' => 'audio/mpeg',
266				'PDF' => 'application/pdf',
267				'PPT' => 'application/vnd.ms-powerpoint',
268				'RTF' => 'text/rtf',
269				'SID' => 'image/x-mrsid',
270				'TXT' => 'text/plain',
271				'XLS' => 'application/vnd.ms-excel',
272				'WMV' => 'video/x-ms-wmv',
273			];
274			if (empty($mime[$imgsize['ext']])) {
275				// if we don’t know what the mimetype is, use something ambiguous
276				$imgsize['mime'] = 'application/octet-stream';
277				if ($this->fileExists()) {
278					// alert the admin if we cannot determine the mime type of an existing file
279					// as the media firewall will be unable to serve this file properly
280					Log::addMediaLog('Media Firewall error: >Unknown Mimetype< for file >' . $this->file . '<');
281				}
282			} else {
283				$imgsize['mime'] = $mime[$imgsize['ext']];
284			}
285		}
286
287		return $imgsize;
288	}
289
290	/**
291	 * Generate a URL for an image.
292	 *
293	 * @param int    $width  Maximum width in pixels
294	 * @param int    $height Maximum height in pixels
295	 * @param string $fit    "crop" or "contain"
296	 *
297	 * @return string
298	 */
299	public function imageUrl($width, $height, $fit) {
300		// Sign the URL, to protect against mass-resize attacks.
301		$glide_key = Site::getPreference('glide-key');
302		if (empty($glide_key)) {
303			$glide_key = bin2hex(random_bytes(128));
304			Site::setPreference('glide-key', $glide_key);
305		}
306
307		if (Auth::accessLevel($this->getTree()) > $this->getTree()->getPreference('SHOW_NO_WATERMARK')) {
308			$mark = 'watermark.png';
309		} else {
310			$mark = '';
311		}
312
313		$url = UrlBuilderFactory::create(WT_BASE_URL, $glide_key)
314			->getUrl('mediafirewall.php', [
315				'mid'       => $this->getXref(),
316				'ged'       => $this->tree->getName(),
317				'w'         => $width,
318				'h'         => $height,
319				'fit'       => $fit,
320				'mark'      => $mark,
321				'markh'     => '100h',
322				'markw'     => '100w',
323				'markalpha' => 25,
324				'or'        => 0, // Intervention uses exif_read_data() which is very buggy.
325			]);
326
327		return $url;
328	}
329
330	/**
331	 * What file extension is used by this file?
332	 *
333	 * @return string
334	 */
335	public function extension() {
336		foreach ($this->mediaFiles() as $media_file) {
337			return $media_file->extension();
338		}
339
340		return '';
341	}
342
343	/**
344	 * What is the mime-type of this object?
345	 * For simplicity and efficiency, use the extension, rather than the contents.
346	 *
347	 * @return string
348	 */
349	public function mimeType() {
350		// Themes contain icon definitions for some/all of these mime-types
351		switch ($this->extension()) {
352		case 'bmp':
353			return 'image/bmp';
354		case 'doc':
355			return 'application/msword';
356		case 'docx':
357			return 'application/msword';
358		case 'ged':
359			return 'text/x-gedcom';
360		case 'gif':
361			return 'image/gif';
362		case 'htm':
363			return 'text/html';
364		case 'html':
365			return 'text/html';
366		case 'jpeg':
367			return 'image/jpeg';
368		case 'jpg':
369			return 'image/jpeg';
370		case 'mov':
371			return 'video/quicktime';
372		case 'mp3':
373			return 'audio/mpeg';
374		case 'mp4':
375			return 'video/mp4';
376		case 'ogv':
377			return 'video/ogg';
378		case 'pdf':
379			return 'application/pdf';
380		case 'png':
381			return 'image/png';
382		case 'rar':
383			return 'application/x-rar-compressed';
384		case 'swf':
385			return 'application/x-shockwave-flash';
386		case 'svg':
387			return 'image/svg';
388		case 'tif':
389			return 'image/tiff';
390		case 'tiff':
391			return 'image/tiff';
392		case 'xls':
393			return 'application/vnd-ms-excel';
394		case 'xlsx':
395			return 'application/vnd-ms-excel';
396		case 'wmv':
397			return 'video/x-ms-wmv';
398		case 'zip':
399			return 'application/zip';
400		default:
401			return 'application/octet-stream';
402		}
403	}
404
405	/**
406	 * Display an image-thumbnail or a media-icon, and add markup for image viewers such as colorbox.
407	 *
408	 * @param int      $width      Pixels
409	 * @param int      $height     Pixels
410	 * @param string   $fit        "crop" or "contain"
411	 * @param string[] $attributes Additional HTML attributes
412	 *
413	 * @return string
414	 */
415	public function displayImage($width, $height, $fit, $attributes = []) {
416		// Default image for external, missing or corrupt images.
417		$image
418			= '<i' .
419			' dir="auto"' . // For the tool-tip
420			' class="icon-mime-' . str_replace('/', '-', $this->mimeType()) . '"' .
421			' title="' . strip_tags($this->getFullName()) . '"' .
422			'></i>';
423
424		// Use a thumbnail image.
425		if ($this->isExternal()) {
426			$src    = $this->getFilename();
427			$srcset = [];
428		} else {
429			// Generate multiple images for displays with higher pixel densities.
430			$src    = $this->imageUrl($width, $height, $fit);
431			$srcset = [];
432			foreach ([2, 3, 4] as $x) {
433				$srcset[] = $this->imageUrl($width * $x, $height * $x, $fit) . ' ' . $x . 'x';
434			}
435		}
436
437		$image = '<img ' . Html::attributes($attributes + [
438					'dir'    => 'auto',
439					'src'    => $src,
440					'srcset' => implode(',', $srcset),
441					'alt'    => strip_tags($this->getFullName()),
442				]) . '>';
443
444		$attributes = Html::attributes([
445			'class' => 'gallery',
446			'type'  => $this->mimeType(),
447			'href'  => $this->imageUrl(0, 0, ''),
448		]);
449
450		return '<a ' . $attributes . '>' . $image . '</a>';
451	}
452
453	/**
454	 * If this object has no name, what do we call it?
455	 *
456	 * @return string
457	 */
458	public function getFallBackName() {
459		if ($this->canShow()) {
460			foreach ($this->mediaFiles() as $media_file) {
461				return $media_file->filename();
462			}
463		}
464
465		return $this->getXref();
466	}
467
468	/**
469	 * Extract names from the GEDCOM record.
470	 */
471	public function extractNames() {
472		// Earlier gedcom versions had level 1 titles
473		// Later gedcom versions had level 2 titles
474		$this->extractNamesFromFacts(2, 'TITL', $this->getFacts('FILE'));
475		$this->extractNamesFromFacts(1, 'TITL', $this->getFacts('TITL'));
476	}
477
478	/**
479	 * This function should be redefined in derived classes to show any major
480	 * identifying characteristics of this record.
481	 *
482	 * @return string
483	 */
484	public function formatListDetails() {
485		ob_start();
486		FunctionsPrintFacts::printMediaLinks('1 OBJE @' . $this->getXref() . '@', 1);
487
488		return ob_get_clean();
489	}
490}
491