xref: /webtrees/app/MediaFile.php (revision 3e983931fdde6db78f1490364106d7d46e77dea7)
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 ErrorException;
19use League\Glide\Urls\UrlBuilderFactory;
20
21/**
22 * A GEDCOM media file.  A media object can contain many media files,
23 * such as scans of both sides of a document, the transcript of an audio
24 * recording, etc.
25 */
26class MediaFile {
27	/** @var string The filename */
28	private $multimedia_file_refn = '';
29
30	/** @var string The file extension; jpeg, txt, mp4, etc. */
31	private $multimedia_format = '';
32
33	/** @var string The type of document; newspaper, microfiche, etc. */
34	private $source_media_type = '';
35	/** @var string The filename */
36
37	/** @var string The name of the document */
38	private $descriptive_title = '';
39
40	/** @var Media $media The media object to which this file belongs */
41	private $media;
42
43	/** @var string */
44	private $fact_id;
45
46	/**
47	 * Create a MediaFile from raw GEDCOM data.
48	 *
49	 * @param string $gedcom
50	 * @param Media  $media
51	 */
52	public function __construct($gedcom, Media $media) {
53		$this->media   = $media;
54		$this->fact_id = md5($gedcom);
55
56		if (preg_match('/^\d FILE (.+)/m', $gedcom, $match)) {
57			$this->multimedia_file_refn = $match[1];
58		}
59
60		if (preg_match('/^\d FORM (.+)/m', $gedcom, $match)) {
61			$this->multimedia_format = $match[1];
62		}
63
64		if (preg_match('/^\d TYPE (.+)/m', $gedcom, $match)) {
65			$this->source_media_type = $match[1];
66		}
67
68		if (preg_match('/^\d TITL (.+)/m', $gedcom, $match)) {
69			$this->descriptive_title = $match[1];
70		}
71	}
72
73	/**
74	 * Get the filename.
75	 *
76	 * @return string
77	 */
78	public function filename(): string {
79		return $this->multimedia_file_refn;
80	}
81
82	/**
83	 * Get the format.
84	 *
85	 * @return string
86	 */
87	public function format(): string {
88		return $this->multimedia_format;
89	}
90
91	/**
92	 * Get the type.
93	 *
94	 * @return string
95	 */
96	public function type(): string {
97		return $this->source_media_type;
98	}
99
100	/**
101	 * Get the title.
102	 *
103	 * @return string
104	 */
105	public function title(): string {
106		return $this->descriptive_title;
107	}
108
109	/**
110	 * Get the fact ID.
111	 *
112	 * @return string
113	 */
114	public function factId(): string {
115		return $this->fact_id;
116	}
117
118	/**
119	 * Display an image-thumbnail or a media-icon, and add markup for image viewers such as colorbox.
120	 *
121	 * @param int      $width      Pixels
122	 * @param int      $height     Pixels
123	 * @param string   $fit        "crop" or "contain"
124	 * @param string[] $attributes Additional HTML attributes
125	 *
126	 * @return string
127	 */
128	public function displayImage($width, $height, $fit, $attributes = []) {
129		if ($this->isExternal()) {
130			$src    = $this->multimedia_file_refn;
131			$srcset = [];
132		} else {
133			// Generate multiple images for displays with higher pixel densities.
134			$src    = $this->imageUrl($width, $height, $fit);
135			$srcset = [];
136			foreach ([2, 3, 4] as $x) {
137				$srcset[] = $this->imageUrl($width * $x, $height * $x, $fit) . ' ' . $x . 'x';
138			}
139		}
140
141		$image = '<img ' . Html::attributes($attributes + [
142					'dir'    => 'auto',
143					'src'    => $src,
144					'srcset' => implode(',', $srcset),
145					'alt'    => strip_tags($this->media->getFullName()),
146				]) . '>';
147
148		$attributes = Html::attributes([
149			'class' => 'gallery',
150			'type'  => $this->mimeType(),
151			'href'  => $this->imageUrl(0, 0, ''),
152		]);
153
154		return '<a ' . $attributes . '>' . $image . '</a>';
155	}
156
157	/**
158	 * A list of image attributes
159	 *
160	 * @return string[]
161	 */
162	public function attributes(): array {
163		$attributes = [];
164
165		if (!$this->isExternal() || $this->fileExists()) {
166			$file = $this->folder() . $this->multimedia_file_refn;
167
168			$attributes['__FILE_SIZE__'] = $this->fileSizeKB();
169
170			$imgsize = getimagesize($file);
171			if (is_array($imgsize) && !empty($imgsize['0'])) {
172				$attributes['__IMAGE_SIZE__'] = I18N::translate('%1$s × %2$s pixels', I18N::number($imgsize['0']), I18N::number($imgsize['1']));
173			}
174		}
175
176		return $attributes;
177	}
178
179	/**
180	 * check if the file exists on this server
181	 *
182	 * @return bool
183	 */
184	public function fileExists() {
185		return file_exists($this->folder() . $this->multimedia_file_refn);
186	}
187
188	/**
189	 * Is the media file actually a URL?
190	 */
191	public function isExternal(): bool {
192		return strpos($this->multimedia_file_refn, '://') !== false;
193	}
194
195	/**
196	 * Is the media file an image?
197	 */
198	public function isImage(): bool {
199		return in_array($this->extension(), ['jpeg', 'jpg', 'gif', 'png']);
200	}
201
202	/**
203	 * Where is the file stored on disk?
204	 */
205	public function folder(): string {
206		return WT_DATA_DIR . $this->media->getTree()->getPreference('MEDIA_DIRECTORY');
207	}
208
209	/**
210	 * A user-friendly view of the file size
211	 *
212	 * @return int
213	 */
214	private function fileSizeBytes(): int {
215		try {
216			return filesize($this->folder() . $this->multimedia_file_refn);
217		} catch (ErrorException $ex) {
218			DebugBar::addThrowable($ex);
219
220			return 0;
221		}
222	}
223
224	/**
225	 * get the media file size in KB
226	 *
227	 * @return string
228	 */
229	public function fileSizeKB() {
230		$size = $this->filesizeBytes();
231		$size = (int) (($size + 1023) / 1024);
232
233		return /* I18N: size of file in KB */ I18N::translate('%s KB', I18N::number($size));
234	}
235
236	///////////////////////////////////////////////////////////////////////////
237
238	/**
239	 * Get the filename on the server - for those (very few!) functions which actually
240	 * need the filename, such as mediafirewall.php and the PDF reports.
241	 *
242	 * @return string
243	 */
244	public function getServerFilename() {
245		$MEDIA_DIRECTORY = $this->media->getTree()->getPreference('MEDIA_DIRECTORY');
246
247		if ($this->isExternal() || !$this->multimedia_file_refn) {
248			// External image, or (in the case of corrupt GEDCOM data) no image at all
249			return $this->multimedia_file_refn;
250		} else {
251			// Main image
252			return WT_DATA_DIR . $MEDIA_DIRECTORY . $this->multimedia_file_refn;
253		}
254	}
255
256	/**
257	 * get image properties
258	 *
259	 * @return array
260	 */
261	public function getImageAttributes() {
262		$imgsize = [];
263		if ($this->fileExists()) {
264			try {
265				$imgsize = getimagesize($this->getServerFilename());
266				if (is_array($imgsize) && !empty($imgsize['0'])) {
267					// this is an image
268					$imageTypes     = ['', 'GIF', 'JPG', 'PNG', 'SWF', 'PSD', 'BMP', 'TIFF', 'TIFF', 'JPC', 'JP2', 'JPX', 'JB2', 'SWC', 'IFF', 'WBMP', 'XBM'];
269					$imgsize['ext'] = $imageTypes[0 + $imgsize[2]];
270					// this is for display purposes, always show non-adjusted info
271					$imgsize['WxH'] = /* I18N: image dimensions, width × height */
272						I18N::translate('%1$s × %2$s pixels', I18N::number($imgsize['0']), I18N::number($imgsize['1']));
273				}
274			} catch (ErrorException $ex) {
275				DebugBar::addThrowable($ex);
276
277				// Not an image, or not a valid image?
278				$imgsize = false;
279			}
280		}
281
282		if (!is_array($imgsize) || empty($imgsize['0'])) {
283			// this is not an image, OR the file doesn’t exist OR it is a url
284			$imgsize[0]      = 0;
285			$imgsize[1]      = 0;
286			$imgsize['ext']  = '';
287			$imgsize['mime'] = '';
288			$imgsize['WxH']  = '';
289		}
290
291		if (empty($imgsize['mime'])) {
292			// this is not an image, OR the file doesn’t exist OR it is a url
293			// set file type equal to the file extension - can’t use parse_url because this may not be a full url
294			$exp            = explode('?', $this->multimedia_file_refn);
295			$imgsize['ext'] = strtoupper(pathinfo($exp[0], PATHINFO_EXTENSION));
296			// all mimetypes we wish to serve with the media firewall must be added to this array.
297			$mime = [
298				'DOC' => 'application/msword',
299				'MOV' => 'video/quicktime',
300				'MP3' => 'audio/mpeg',
301				'PDF' => 'application/pdf',
302				'PPT' => 'application/vnd.ms-powerpoint',
303				'RTF' => 'text/rtf',
304				'SID' => 'image/x-mrsid',
305				'TXT' => 'text/plain',
306				'XLS' => 'application/vnd.ms-excel',
307				'WMV' => 'video/x-ms-wmv',
308			];
309			if (empty($mime[$imgsize['ext']])) {
310				// if we don’t know what the mimetype is, use something ambiguous
311				$imgsize['mime'] = 'application/octet-stream';
312				if ($this->fileExists()) {
313					// alert the admin if we cannot determine the mime type of an existing file
314					// as the media firewall will be unable to serve this file properly
315					Log::addMediaLog('Media Firewall error: >Unknown Mimetype< for file >' . $this->multimedia_file_refn . '<');
316				}
317			} else {
318				$imgsize['mime'] = $mime[$imgsize['ext']];
319			}
320		}
321
322		return $imgsize;
323	}
324
325	/**
326	 * Generate a URL for an image.
327	 *
328	 * @param int    $width  Maximum width in pixels
329	 * @param int    $height Maximum height in pixels
330	 * @param string $fit    "crop" or "contain"
331	 *
332	 * @return string
333	 */
334	public function imageUrl($width, $height, $fit) {
335		// Sign the URL, to protect against mass-resize attacks.
336		$glide_key = Site::getPreference('glide-key');
337		if (empty($glide_key)) {
338			$glide_key = bin2hex(random_bytes(128));
339			Site::setPreference('glide-key', $glide_key);
340		}
341
342		if (Auth::accessLevel($this->media->getTree()) > $this->media->getTree()->getPreference('SHOW_NO_WATERMARK')) {
343			$mark = 'watermark.png';
344		} else {
345			$mark = '';
346		}
347
348		$url_builder = UrlBuilderFactory::create(WT_BASE_URL, $glide_key);
349
350		$url = $url_builder->getUrl('index.php', [
351			'route'     => 'media-thumbnail',
352			'xref'      => $this->media->getXref(),
353			'ged'       => $this->media->getTree()->getName(),
354			'fact_id'   => $this->fact_id,
355			'w'         => $width,
356			'h'         => $height,
357			'fit'       => $fit,
358			'mark'      => $mark,
359			'markh'     => '100h',
360			'markw'     => '100w',
361			'markalpha' => 25,
362			'or'        => 0, // Intervention uses exif_read_data() which is very buggy.
363		]);
364
365		return $url;
366	}
367
368	/**
369	 * What file extension is used by this file?
370	 *
371	 * @return string
372	 */
373	public function extension() {
374		if (preg_match('/\.([a-zA-Z0-9]+)$/', $this->multimedia_file_refn, $match)) {
375			return strtolower($match[1]);
376		} else {
377			return '';
378		}
379	}
380
381	/**
382	 * What is the mime-type of this object?
383	 * For simplicity and efficiency, use the extension, rather than the contents.
384	 *
385	 * @return string
386	 */
387	public function mimeType() {
388		// Themes contain icon definitions for some/all of these mime-types
389		switch ($this->extension()) {
390			case 'bmp':
391				return 'image/bmp';
392			case 'doc':
393				return 'application/msword';
394			case 'docx':
395				return 'application/msword';
396			case 'ged':
397				return 'text/x-gedcom';
398			case 'gif':
399				return 'image/gif';
400			case 'htm':
401				return 'text/html';
402			case 'html':
403				return 'text/html';
404			case 'jpeg':
405				return 'image/jpeg';
406			case 'jpg':
407				return 'image/jpeg';
408			case 'mov':
409				return 'video/quicktime';
410			case 'mp3':
411				return 'audio/mpeg';
412			case 'mp4':
413				return 'video/mp4';
414			case 'ogv':
415				return 'video/ogg';
416			case 'pdf':
417				return 'application/pdf';
418			case 'png':
419				return 'image/png';
420			case 'rar':
421				return 'application/x-rar-compressed';
422			case 'swf':
423				return 'application/x-shockwave-flash';
424			case 'svg':
425				return 'image/svg';
426			case 'tif':
427				return 'image/tiff';
428			case 'tiff':
429				return 'image/tiff';
430			case 'xls':
431				return 'application/vnd-ms-excel';
432			case 'xlsx':
433				return 'application/vnd-ms-excel';
434			case 'wmv':
435				return 'video/x-ms-wmv';
436			case 'zip':
437				return 'application/zip';
438			default:
439				return 'application/octet-stream';
440		}
441	}
442}
443