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