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