xref: /webtrees/app/Media.php (revision 13abd6f3a37322f885d85df150e105d27ad81f8d)
1<?php
2/**
3 * webtrees: online genealogy
4 * Copyright (C) 2016 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\FunctionsMedia;
19use Fisharebest\Webtrees\Functions\FunctionsPrintFacts;
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	/** @var string The "TITL" value from the GEDCOM */
29	private $title = '';
30
31	/** @var string The "FILE" value from the GEDCOM */
32	private $file = '';
33
34	/**
35	 * Create a GedcomRecord object from raw GEDCOM data.
36	 *
37	 * @param string      $xref
38	 * @param string      $gedcom  an empty string for new/pending records
39	 * @param string|null $pending null for a record with no pending edits,
40	 *                             empty string for records with pending deletions
41	 * @param Tree        $tree
42	 */
43	public function __construct($xref, $gedcom, $pending, $tree) {
44		parent::__construct($xref, $gedcom, $pending, $tree);
45
46		if (preg_match('/\n1 FILE (.+)/', $gedcom . $pending, $match)) {
47			$this->file = $match[1];
48		}
49		if (preg_match('/\n\d TITL (.+)/', $gedcom . $pending, $match)) {
50			$this->title = $match[1];
51		}
52	}
53
54	/**
55	 * Each object type may have its own special rules, and re-implement this function.
56	 *
57	 * @param int $access_level
58	 *
59	 * @return bool
60	 */
61	protected function canShowByType($access_level) {
62		// Hide media objects if they are attached to private records
63		$linked_ids = Database::prepare(
64			"SELECT l_from FROM `##link` WHERE l_to = ? AND l_file = ?"
65		)->execute([
66			$this->xref, $this->tree->getTreeId(),
67		])->fetchOneColumn();
68		foreach ($linked_ids as $linked_id) {
69			$linked_record = GedcomRecord::getInstance($linked_id, $this->tree);
70			if ($linked_record && !$linked_record->canShow($access_level)) {
71				return false;
72			}
73		}
74
75		// ... otherwise apply default behaviour
76		return parent::canShowByType($access_level);
77	}
78
79	/**
80	 * Fetch data from the database
81	 *
82	 * @param string $xref
83	 * @param int    $tree_id
84	 *
85	 * @return null|string
86	 */
87	protected static function fetchGedcomRecord($xref, $tree_id) {
88		return Database::prepare(
89			"SELECT m_gedcom FROM `##media` WHERE m_id = :xref AND m_file = :tree_id"
90		)->execute([
91			'xref'    => $xref,
92			'tree_id' => $tree_id,
93		])->fetchOne();
94	}
95
96	/**
97	 * Get the first note attached to this media object
98	 *
99	 * @return null|string
100	 */
101	public function getNote() {
102		$note = $this->getFirstFact('NOTE');
103		if ($note) {
104			$text = $note->getValue();
105			if (preg_match('/^@' . WT_REGEX_XREF . '@$/', $text)) {
106				$text = $note->getTarget()->getNote();
107			}
108
109			return $text;
110		} else {
111			return '';
112		}
113	}
114
115	/**
116	 * Get the main media filename
117	 *
118	 * @return string
119	 */
120	public function getFilename() {
121		return $this->file;
122	}
123
124	/**
125	 * Get the media's title (name)
126	 *
127	 * @return string
128	 */
129	public function getTitle() {
130		return $this->title;
131	}
132
133	/**
134	 * Get the filename on the server - for those (very few!) functions which actually
135	 * need the filename, such as mediafirewall.php and the PDF reports.
136	 *
137	 * @param string $which
138	 *
139	 * @return string
140	 */
141	public function getServerFilename($which = 'main') {
142		$MEDIA_DIRECTORY = $this->tree->getPreference('MEDIA_DIRECTORY');
143		$THUMBNAIL_WIDTH = $this->tree->getPreference('THUMBNAIL_WIDTH');
144
145		if ($this->isExternal() || !$this->file) {
146			// External image, or (in the case of corrupt GEDCOM data) no image at all
147			return $this->file;
148		} elseif ($which == 'main') {
149			// Main image
150			return WT_DATA_DIR . $MEDIA_DIRECTORY . $this->file;
151		} else {
152			// Thumbnail
153			$file = WT_DATA_DIR . $MEDIA_DIRECTORY . 'thumbs/' . $this->file;
154			// Does the thumbnail exist?
155			if (file_exists($file)) {
156				return $file;
157			}
158			// Does a user-generated thumbnail exist?
159			$user_thumb = preg_replace('/\.[a-z0-9]{3,5}$/i', '.png', $file);
160			if (file_exists($user_thumb)) {
161				return $user_thumb;
162			}
163			// Does the folder exist for this thumbnail?
164			if (!is_dir(dirname($file)) && !File::mkdir(dirname($file))) {
165				Log::addMediaLog('The folder ' . dirname($file) . ' could not be created for ' . $this->getXref());
166
167				return $file;
168			}
169			// Is there a corresponding main image?
170			$main_file = WT_DATA_DIR . $MEDIA_DIRECTORY . $this->file;
171			if (!file_exists($main_file)) {
172				Log::addMediaLog('The file ' . $main_file . ' does not exist for ' . $this->getXref());
173
174				return $file;
175			}
176			// Try to create a thumbnail automatically
177
178			try {
179				$imgsize = getimagesize($main_file);
180				// Image small enough to be its own thumbnail?
181				if ($imgsize[0] > 0 && $imgsize[0] <= $THUMBNAIL_WIDTH) {
182					try {
183						copy($main_file, $file);
184						Log::addMediaLog('Thumbnail created for ' . $main_file . ' (copy of main image)');
185					} catch (\ErrorException $ex) {
186						Log::addMediaLog('Thumbnail could not be created for ' . $main_file . ' (copy of main image)');
187					}
188				} else {
189					if (FunctionsMedia::hasMemoryForImage($main_file)) {
190						try {
191							switch ($imgsize['mime']) {
192							case 'image/png':
193								$main_image = imagecreatefrompng($main_file);
194								break;
195							case 'image/gif':
196								$main_image = imagecreatefromgif($main_file);
197								break;
198							case 'image/jpeg':
199								$main_image = imagecreatefromjpeg($main_file);
200								break;
201							default:
202								return $file; // Nothing else we can do :-(
203							}
204							if ($main_image) {
205								// How big should the thumbnail be?
206								$width       = $THUMBNAIL_WIDTH;
207								$height      = round($imgsize[1] * ($width / $imgsize[0]));
208								$thumb_image = imagecreatetruecolor($width, $height);
209								// Create a transparent background, instead of the default black one
210								imagesavealpha($thumb_image, true);
211								imagefill($thumb_image, 0, 0, imagecolorallocatealpha($thumb_image, 0, 0, 0, 127));
212								// Shrink the image
213								imagecopyresampled($thumb_image, $main_image, 0, 0, 0, 0, $width, $height, $imgsize[0], $imgsize[1]);
214								switch ($imgsize['mime']) {
215								case 'image/png':
216									imagepng($thumb_image, $file);
217									break;
218								case 'image/gif':
219									imagegif($thumb_image, $file);
220									break;
221								case 'image/jpeg':
222									imagejpeg($thumb_image, $file);
223									break;
224								}
225								imagedestroy($main_image);
226								imagedestroy($thumb_image);
227								Log::addMediaLog('Thumbnail created for ' . $main_file);
228							}
229						} catch (\ErrorException $ex) {
230							Log::addMediaLog('Failed to create thumbnail for ' . $main_file . ' (' . $ex . ')');
231						}
232					} else {
233						Log::addMediaLog('Not enough memory to create thumbnail for ' . $main_file);
234					}
235				}
236			} catch (\ErrorException $ex) {
237				// Not an image, or not a valid image?
238			}
239
240			return $file;
241		}
242	}
243
244	/**
245	 * check if the file exists on this server
246	 *
247	 * @param string $which specify either 'main' or 'thumb'
248	 *
249	 * @return bool
250	 */
251	public function fileExists($which = 'main') {
252		return file_exists($this->getServerFilename($which));
253	}
254
255	/**
256	 * Determine if the file is an external url
257	 *
258	 * @return bool
259	 */
260	public function isExternal() {
261		return strpos($this->file, '://') !== false;
262	}
263
264	/**
265	 * get the media file size in KB
266	 *
267	 * @param string $which specify either 'main' or 'thumb'
268	 *
269	 * @return string
270	 */
271	public function getFilesize($which = 'main') {
272		$size = $this->getFilesizeraw($which);
273		// Round up to the nearest KB.
274		$size = (int) (($size + 1023) / 1024);
275
276		return /* I18N: size of file in KB */ I18N::translate('%s KB', I18N::number($size));
277	}
278
279	/**
280	 * get the media file size, unformatted
281	 *
282	 * @param string $which specify either 'main' or 'thumb'
283	 *
284	 * @return int
285	 */
286	public function getFilesizeraw($which = 'main') {
287		try {
288			return filesize($this->getServerFilename($which));
289		} catch (\ErrorException $ex) {
290			return 0;
291		}
292	}
293
294	/**
295	 * get filemtime for the media file
296	 *
297	 * @param string $which specify either 'main' or 'thumb'
298	 *
299	 * @return int
300	 */
301	public function getFiletime($which = 'main') {
302		try {
303			return filemtime($this->getServerFilename($which));
304		} catch (\ErrorException $ex) {
305			return 0;
306		}
307	}
308
309	/**
310	 * Generate an etag specific to this media item and the current user
311	 *
312	 * @param string $which - specify either 'main' or 'thumb'
313	 *
314	 * @return string
315	 */
316	public function getEtag($which = 'main') {
317		if ($this->isExternal()) {
318			// etag not really defined for external media
319
320			return '';
321		}
322		$etag_string = basename($this->getServerFilename($which)) . $this->getFiletime($which) . $this->tree->getName() . Auth::accessLevel($this->tree) . $this->tree->getPreference('SHOW_NO_WATERMARK');
323		$etag_string = dechex(crc32($etag_string));
324
325		return $etag_string;
326	}
327
328	/**
329	 * Deprecated? This does not need to be a function here.
330	 *
331	 * @return string
332	 */
333	public function getMediaType() {
334		if (preg_match('/\n\d TYPE (.+)/', $this->gedcom, $match)) {
335			return strtolower($match[1]);
336		} else {
337			return '';
338		}
339	}
340
341	/**
342	 * Is this object marked as a highlighted image?
343	 *
344	 * @return string
345	 */
346	public function isPrimary() {
347		if (preg_match('/\n\d _PRIM ([YN])/', $this->getGedcom(), $match)) {
348			return $match[1];
349		} else {
350			return '';
351		}
352	}
353
354	/**
355	 * get image properties
356	 *
357	 * @param string $which     specify either 'main' or 'thumb'
358	 * @param int    $addWidth  amount to add to width
359	 * @param int    $addHeight amount to add to height
360	 *
361	 * @return array
362	 */
363	public function getImageAttributes($which = 'main', $addWidth = 0, $addHeight = 0) {
364		$THUMBNAIL_WIDTH = $this->tree->getPreference('THUMBNAIL_WIDTH');
365
366		$var = $which . 'imagesize';
367		if (!empty($this->$var)) {
368			return $this->$var;
369		}
370		$imgsize = [];
371		if ($this->fileExists($which)) {
372
373			try {
374				$imgsize = getimagesize($this->getServerFilename($which));
375				if (is_array($imgsize) && !empty($imgsize['0'])) {
376					// this is an image
377					$imgsize[0]      = $imgsize[0] + 0;
378					$imgsize[1]      = $imgsize[1] + 0;
379					$imgsize['adjW'] = $imgsize[0] + $addWidth; // adjusted width
380					$imgsize['adjH'] = $imgsize[1] + $addHeight; // adjusted height
381					$imageTypes      = ['', 'GIF', 'JPG', 'PNG', 'SWF', 'PSD', 'BMP', 'TIFF', 'TIFF', 'JPC', 'JP2', 'JPX', 'JB2', 'SWC', 'IFF', 'WBMP', 'XBM'];
382					$imgsize['ext']  = $imageTypes[0 + $imgsize[2]];
383					// this is for display purposes, always show non-adjusted info
384					$imgsize['WxH']   =
385						/* I18N: image dimensions, width × height */
386						I18N::translate('%1$s × %2$s pixels', I18N::number($imgsize['0']), I18N::number($imgsize['1']));
387					$imgsize['imgWH'] = ' width="' . $imgsize['adjW'] . '" height="' . $imgsize['adjH'] . '" ';
388					if (($which == 'thumb') && ($imgsize['0'] > $THUMBNAIL_WIDTH)) {
389						// don’t let large images break the dislay
390						$imgsize['imgWH'] = ' width="' . $THUMBNAIL_WIDTH . '" ';
391					}
392				}
393			} catch (\ErrorException $ex) {
394				// Not an image, or not a valid image?
395				$imgsize = false;
396			}
397		}
398
399		if (!is_array($imgsize) || empty($imgsize['0'])) {
400			// this is not an image, OR the file doesn’t exist OR it is a url
401			$imgsize[0]       = 0;
402			$imgsize[1]       = 0;
403			$imgsize['adjW']  = 0;
404			$imgsize['adjH']  = 0;
405			$imgsize['ext']   = '';
406			$imgsize['mime']  = '';
407			$imgsize['WxH']   = '';
408			$imgsize['imgWH'] = '';
409			if ($this->isExternal()) {
410				// don’t let large external images break the dislay
411				$imgsize['imgWH'] = ' width="' . $THUMBNAIL_WIDTH . '" ';
412			}
413		}
414
415		if (empty($imgsize['mime'])) {
416			// this is not an image, OR the file doesn’t exist OR it is a url
417			// set file type equal to the file extension - can’t use parse_url because this may not be a full url
418			$exp            = explode('?', $this->file);
419			$imgsize['ext'] = strtoupper(pathinfo($exp[0], PATHINFO_EXTENSION));
420			// all mimetypes we wish to serve with the media firewall must be added to this array.
421			$mime = [
422				'DOC' => 'application/msword',
423				'MOV' => 'video/quicktime',
424				'MP3' => 'audio/mpeg',
425				'PDF' => 'application/pdf',
426				'PPT' => 'application/vnd.ms-powerpoint',
427				'RTF' => 'text/rtf',
428				'SID' => 'image/x-mrsid',
429				'TXT' => 'text/plain',
430				'XLS' => 'application/vnd.ms-excel',
431				'WMV' => 'video/x-ms-wmv',
432			];
433			if (empty($mime[$imgsize['ext']])) {
434				// if we don’t know what the mimetype is, use something ambiguous
435				$imgsize['mime'] = 'application/octet-stream';
436				if ($this->fileExists($which)) {
437					// alert the admin if we cannot determine the mime type of an existing file
438					// as the media firewall will be unable to serve this file properly
439					Log::addMediaLog('Media Firewall error: >Unknown Mimetype< for file >' . $this->file . '<');
440				}
441			} else {
442				$imgsize['mime'] = $mime[$imgsize['ext']];
443			}
444		}
445		$this->$var = $imgsize;
446
447		return $this->$var;
448	}
449
450	/**
451	 * Generate a URL directly to the media file
452	 *
453	 * @param string $which
454	 * @param bool   $download
455	 *
456	 * @return string
457	 */
458	public function getHtmlUrlDirect($which = 'main', $download = false) {
459		// “cb” is “cache buster”, so clients will make new request if anything significant about the user or the file changes
460		// The extension is there so that image viewers (e.g. colorbox) can do something sensible
461		$thumbstr    = ($which == 'thumb') ? '&amp;thumb=1' : '';
462		$downloadstr = ($download) ? '&dl=1' : '';
463
464		return
465			'mediafirewall.php?mid=' . $this->getXref() . $thumbstr . $downloadstr .
466			'&amp;ged=' . $this->tree->getNameUrl() .
467			'&amp;cb=' . $this->getEtag($which);
468	}
469
470	/**
471	 * What file extension is used by this file?
472	 *
473	 * @return string
474	 */
475	public function extension() {
476		if (preg_match('/\.([a-zA-Z0-9]+)$/', $this->file, $match)) {
477			return strtolower($match[1]);
478		} else {
479			return '';
480		}
481	}
482
483	/**
484	 * What is the mime-type of this object?
485	 * For simplicity and efficiency, use the extension, rather than the contents.
486	 *
487	 * @return string
488	 */
489	public function mimeType() {
490		// Themes contain icon definitions for some/all of these mime-types
491		switch ($this->extension()) {
492		case 'bmp':
493			return 'image/bmp';
494		case 'doc':
495			return 'application/msword';
496		case 'docx':
497			return 'application/msword';
498		case 'ged':
499			return 'text/x-gedcom';
500		case 'gif':
501			return 'image/gif';
502		case 'htm':
503			return 'text/html';
504		case 'html':
505			return 'text/html';
506		case 'jpeg':
507			return 'image/jpeg';
508		case 'jpg':
509			return 'image/jpeg';
510		case 'mov':
511			return 'video/quicktime';
512		case 'mp3':
513			return 'audio/mpeg';
514		case 'ogv':
515			return 'video/ogg';
516		case 'pdf':
517			return 'application/pdf';
518		case 'png':
519			return 'image/png';
520		case 'rar':
521			return 'application/x-rar-compressed';
522		case 'swf':
523			return 'application/x-shockwave-flash';
524		case 'svg':
525			return 'image/svg';
526		case 'tif':
527			return 'image/tiff';
528		case 'tiff':
529			return 'image/tiff';
530		case 'xls':
531			return 'application/vnd-ms-excel';
532		case 'xlsx':
533			return 'application/vnd-ms-excel';
534		case 'wmv':
535			return 'video/x-ms-wmv';
536		case 'zip':
537			return 'application/zip';
538		default:
539			return 'application/octet-stream';
540		}
541	}
542
543	/**
544	 * Display an image-thumbnail or a media-icon, and add markup for image viewers such as colorbox.
545	 *
546	 * @return string
547	 */
548	public function displayImage() {
549		if ($this->isExternal() || !file_exists($this->getServerFilename('thumb'))) {
550			// Use an icon
551			$mime_type = str_replace('/', '-', $this->mimeType());
552			$image     =
553				'<i' .
554				' dir="' . 'auto' . '"' . // For the tool-tip
555				' class="' . 'icon-mime-' . $mime_type . '"' .
556				' title="' . strip_tags($this->getFullName()) . '"' .
557				'></i>';
558		} else {
559			$imgsize = getimagesize($this->getServerFilename('thumb'));
560			// Use a thumbnail image
561			$image =
562				'<img' .
563				' dir="' . 'auto' . '"' . // For the tool-tip
564				' src="' . $this->getHtmlUrlDirect('thumb') . '"' .
565				' alt="' . strip_tags($this->getFullName()) . '"' .
566				' title="' . strip_tags($this->getFullName()) . '"' .
567				' ' . $imgsize[3] . // height="yyy" width="xxx"
568				'>';
569		}
570
571		return
572			'<a' .
573			' class="' . 'gallery' . '"' .
574			' href="' . $this->getHtmlUrlDirect('main') . '"' .
575			' type="' . $this->mimeType() . '"' .
576			' data-obje-url="' . $this->getHtmlUrl() . '"' .
577			' data-obje-note="' . Filter::escapeHtml($this->getNote()) . '"' .
578			' data-title="' . Filter::escapeHtml($this->getFullName()) . '"' .
579			'>' . $image . '</a>';
580	}
581
582	/**
583	 * If this object has no name, what do we call it?
584	 *
585	 * @return string
586	 */
587	public function getFallBackName() {
588		if ($this->canShow()) {
589			return basename($this->file);
590		} else {
591			return $this->getXref();
592		}
593	}
594
595	/**
596	 * Extract names from the GEDCOM record.
597	 */
598	public function extractNames() {
599		// Earlier gedcom versions had level 1 titles
600		// Later gedcom versions had level 2 titles
601		$this->extractNamesFromFacts(2, 'TITL', $this->getFacts('FILE'));
602		$this->extractNamesFromFacts(1, 'TITL', $this->getFacts('TITL'));
603	}
604
605	/**
606	 * This function should be redefined in derived classes to show any major
607	 * identifying characteristics of this record.
608	 *
609	 * @return string
610	 */
611	public function formatListDetails() {
612		ob_start();
613		FunctionsPrintFacts::printMediaLinks('1 OBJE @' . $this->getXref() . '@', 1);
614
615		return ob_get_clean();
616	}
617}
618