xref: /webtrees/app/Media.php (revision 64b90bf1b61d92cb62503a33536bc81455e0d2e3)
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	 * Get the first media file that contains an image.
87	 *
88	 * @return MediaFile|null
89	 */
90	public function firstImageFile() {
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		$media_file = $this->firstImageFile();
126
127		if ($media_file === null) {
128			return '';
129		} else {
130			return $media_file->filename();
131		}
132	}
133
134	/**
135	 * Get the media's title (name)
136	 *
137	 * @return string
138	 */
139	public function getTitle() {
140		return $this->title;
141	}
142
143	/**
144	 * Get the filename on the server - for those (very few!) functions which actually
145	 * need the filename, such as mediafirewall.php and the PDF reports.
146	 *
147	 * @return string
148	 */
149	public function getServerFilename() {
150		$media_file = $this->firstImageFile();
151
152		if ($media_file === null) {
153			return '';
154		} else {
155			return $media_file->filename();
156		}
157	}
158
159	/**
160	 * check if the file exists on this server
161	 *
162	 * @return bool
163	 */
164	public function fileExists() {
165		return file_exists($this->getServerFilename());
166	}
167
168	/**
169	 * Determine if the file is an external url
170	 *
171	 * @return bool
172	 */
173	public function isExternal() {
174		foreach ($this->mediaFiles() as $media_file) {
175			if (strpos($media_file->filename(), '://') !== false) {
176				return true;
177			}
178		}
179
180		return false;
181	}
182
183	/**
184	 * get the media file size in KB
185	 *
186	 * @return string
187	 */
188	public function getFilesize() {
189		$size = $this->getFilesizeraw();
190		// Round up to the nearest KB.
191		$size = (int) (($size + 1023) / 1024);
192
193		return /* I18N: size of file in KB */
194			I18N::translate('%s KB', I18N::number($size));
195	}
196
197	/**
198	 * get the media file size, unformatted
199	 *
200	 * @return int
201	 */
202	public function getFilesizeraw() {
203		try {
204			return filesize($this->getServerFilename());
205		} catch (\ErrorException $ex) {
206			DebugBar::addThrowable($ex);
207
208			return 0;
209		}
210	}
211
212	/**
213	 * Deprecated? This does not need to be a function here.
214	 *
215	 * @return string
216	 */
217	public function getMediaType() {
218		if (preg_match('/\n\d TYPE (.+)/', $this->gedcom, $match)) {
219			return strtolower($match[1]);
220		} else {
221			return '';
222		}
223	}
224
225	/**
226	 * get image properties
227	 *
228	 * @return array
229	 */
230	public function getImageAttributes() {
231		$imgsize = [];
232		if ($this->fileExists()) {
233			try {
234				$imgsize = getimagesize($this->getServerFilename());
235				if (is_array($imgsize) && !empty($imgsize['0'])) {
236					// this is an image
237					$imageTypes     = ['', 'GIF', 'JPG', 'PNG', 'SWF', 'PSD', 'BMP', 'TIFF', 'TIFF', 'JPC', 'JP2', 'JPX', 'JB2', 'SWC', 'IFF', 'WBMP', 'XBM'];
238					$imgsize['ext'] = $imageTypes[0 + $imgsize[2]];
239					// this is for display purposes, always show non-adjusted info
240					$imgsize['WxH'] = /* I18N: image dimensions, width × height */
241						I18N::translate('%1$s × %2$s pixels', I18N::number($imgsize['0']), I18N::number($imgsize['1']));
242				}
243			} catch (\ErrorException $ex) {
244				DebugBar::addThrowable($ex);
245
246				// Not an image, or not a valid image?
247				$imgsize = false;
248			}
249		}
250
251		if (!is_array($imgsize) || empty($imgsize['0'])) {
252			// this is not an image, OR the file doesn’t exist OR it is a url
253			$imgsize[0]      = 0;
254			$imgsize[1]      = 0;
255			$imgsize['ext']  = '';
256			$imgsize['mime'] = '';
257			$imgsize['WxH']  = '';
258		}
259
260		if (empty($imgsize['mime'])) {
261			// this is not an image, OR the file doesn’t exist OR it is a url
262			// set file type equal to the file extension - can’t use parse_url because this may not be a full url
263			$exp            = explode('?', $this->file);
264			$imgsize['ext'] = strtoupper(pathinfo($exp[0], PATHINFO_EXTENSION));
265			// all mimetypes we wish to serve with the media firewall must be added to this array.
266			$mime = [
267				'DOC' => 'application/msword',
268				'MOV' => 'video/quicktime',
269				'MP3' => 'audio/mpeg',
270				'PDF' => 'application/pdf',
271				'PPT' => 'application/vnd.ms-powerpoint',
272				'RTF' => 'text/rtf',
273				'SID' => 'image/x-mrsid',
274				'TXT' => 'text/plain',
275				'XLS' => 'application/vnd.ms-excel',
276				'WMV' => 'video/x-ms-wmv',
277			];
278			if (empty($mime[$imgsize['ext']])) {
279				// if we don’t know what the mimetype is, use something ambiguous
280				$imgsize['mime'] = 'application/octet-stream';
281				if ($this->fileExists()) {
282					// alert the admin if we cannot determine the mime type of an existing file
283					// as the media firewall will be unable to serve this file properly
284					Log::addMediaLog('Media Firewall error: >Unknown Mimetype< for file >' . $this->file . '<');
285				}
286			} else {
287				$imgsize['mime'] = $mime[$imgsize['ext']];
288			}
289		}
290
291		return $imgsize;
292	}
293
294	/**
295	 * Generate a URL for an image.
296	 *
297	 * @param int    $width  Maximum width in pixels
298	 * @param int    $height Maximum height in pixels
299	 * @param string $fit    "crop" or "contain"
300	 *
301	 * @return string
302	 */
303	public function imageUrl($width, $height, $fit) {
304		// Sign the URL, to protect against mass-resize attacks.
305		$glide_key = Site::getPreference('glide-key');
306		if (empty($glide_key)) {
307			$glide_key = bin2hex(random_bytes(128));
308			Site::setPreference('glide-key', $glide_key);
309		}
310
311		if (Auth::accessLevel($this->getTree()) > $this->getTree()->getPreference('SHOW_NO_WATERMARK')) {
312			$mark = 'watermark.png';
313		} else {
314			$mark = '';
315		}
316
317		$url = UrlBuilderFactory::create(WT_BASE_URL, $glide_key)
318			->getUrl('mediafirewall.php', [
319				'mid'       => $this->getXref(),
320				'ged'       => $this->tree->getName(),
321				'w'         => $width,
322				'h'         => $height,
323				'fit'       => $fit,
324				'mark'      => $mark,
325				'markh'     => '100h',
326				'markw'     => '100w',
327				'markalpha' => 25,
328				'or'        => 0, // Intervention uses exif_read_data() which is very buggy.
329			]);
330
331		return $url;
332	}
333
334	/**
335	 * What file extension is used by this file?
336	 *
337	 * @return string
338	 */
339	public function extension() {
340		foreach ($this->mediaFiles() as $media_file) {
341			return $media_file->extension();
342		}
343
344		return '';
345	}
346
347	/**
348	 * What is the mime-type of this object?
349	 * For simplicity and efficiency, use the extension, rather than the contents.
350	 *
351	 * @return string
352	 */
353	public function mimeType() {
354		// Themes contain icon definitions for some/all of these mime-types
355		switch ($this->extension()) {
356		case 'bmp':
357			return 'image/bmp';
358		case 'doc':
359			return 'application/msword';
360		case 'docx':
361			return 'application/msword';
362		case 'ged':
363			return 'text/x-gedcom';
364		case 'gif':
365			return 'image/gif';
366		case 'htm':
367			return 'text/html';
368		case 'html':
369			return 'text/html';
370		case 'jpeg':
371			return 'image/jpeg';
372		case 'jpg':
373			return 'image/jpeg';
374		case 'mov':
375			return 'video/quicktime';
376		case 'mp3':
377			return 'audio/mpeg';
378		case 'mp4':
379			return 'video/mp4';
380		case 'ogv':
381			return 'video/ogg';
382		case 'pdf':
383			return 'application/pdf';
384		case 'png':
385			return 'image/png';
386		case 'rar':
387			return 'application/x-rar-compressed';
388		case 'swf':
389			return 'application/x-shockwave-flash';
390		case 'svg':
391			return 'image/svg';
392		case 'tif':
393			return 'image/tiff';
394		case 'tiff':
395			return 'image/tiff';
396		case 'xls':
397			return 'application/vnd-ms-excel';
398		case 'xlsx':
399			return 'application/vnd-ms-excel';
400		case 'wmv':
401			return 'video/x-ms-wmv';
402		case 'zip':
403			return 'application/zip';
404		default:
405			return 'application/octet-stream';
406		}
407	}
408
409	/**
410	 * Display an image-thumbnail or a media-icon, and add markup for image viewers such as colorbox.
411	 *
412	 * @param int      $width      Pixels
413	 * @param int      $height     Pixels
414	 * @param string   $fit        "crop" or "contain"
415	 * @param string[] $attributes Additional HTML attributes
416	 *
417	 * @return string
418	 */
419	public function displayImage($width, $height, $fit, $attributes = []) {
420		$media_file = $this->firstImageFile();
421
422		if ($media_file !== null) {
423			return $media_file->displayImage($width, $height, $fit, $attributes);
424		}
425
426		return 'EEK!!!';
427		// Default image for external, missing or corrupt images.
428		$image
429			= '<i' .
430			' dir="auto"' . // For the tool-tip
431			' class="icon-mime-' . str_replace('/', '-', $this->mimeType()) . '"' .
432			' title="' . strip_tags($this->getFullName()) . '"' .
433			'></i>';
434
435		// Use a thumbnail image.
436		if ($this->isExternal()) {
437			$src    = $this->getFilename();
438			$srcset = [];
439		} else {
440			// Generate multiple images for displays with higher pixel densities.
441			$src    = $this->imageUrl($width, $height, $fit);
442			$srcset = [];
443			foreach ([2, 3, 4] as $x) {
444				$srcset[] = $this->imageUrl($width * $x, $height * $x, $fit) . ' ' . $x . 'x';
445			}
446		}
447
448		$image = '<img ' . Html::attributes($attributes + [
449					'dir'    => 'auto',
450					'src'    => $src,
451					'srcset' => implode(',', $srcset),
452					'alt'    => strip_tags($this->getFullName()),
453				]) . '>';
454
455		$attributes = Html::attributes([
456			'class' => 'gallery',
457			'type'  => $this->mimeType(),
458			'href'  => $this->imageUrl(0, 0, ''),
459		]);
460
461		return '<a ' . $attributes . '>' . $image . '</a>';
462	}
463
464	/**
465	 * Extract names from the GEDCOM record.
466	 */
467	public function extractNames() {
468		$names = [];
469		foreach ($this->mediaFiles() as $media_file) {
470			$names[] = $media_file->title();
471			$names[] = $media_file->filename();
472		}
473		$names = array_filter(array_unique($names));
474
475		if (empty($names)) {
476			$names[] = $this->getFallBackName();
477		}
478
479		foreach ($names as $name) {
480			$this->addName(static::RECORD_TYPE, $name, null);
481		}
482	}
483
484	/**
485	 * This function should be redefined in derived classes to show any major
486	 * identifying characteristics of this record.
487	 *
488	 * @return string
489	 */
490	public function formatListDetails() {
491		ob_start();
492		FunctionsPrintFacts::printMediaLinks('1 OBJE @' . $this->getXref() . '@', 1);
493
494		return ob_get_clean();
495	}
496}
497