Issues (752)

server/includes/download_attachment.php (10 issues)

1
<?php
2
3
// required to handle php errors
4
require_once __DIR__ . '/exceptions/class.ZarafaErrorException.php';
5
require_once __DIR__ . '/download_base.php';
6
7
/**
8
 * DownloadAttachment.
9
 *
10
 * A class to manage downloading of attachments from message, additionally
11
 * this class can be used to download inline images from message as well.
12
 *
13
 * Main reason to create this class is to not pollute the global namespace.
14
 */
15
class DownloadAttachment extends DownloadBase {
16
	/**
17
	 * Content disposition type for the attachment that will be sent with header with the attachment data
18
	 * Possible values are 'inline' and 'attachment'. When content-type is application/octet-stream and
19
	 * content disposition type is 'attachment' then browser will show dialog to save attachment as instead of
20
	 * directly displaying content inline.
21
	 */
22
	private $contentDispositionType;
23
24
	/**
25
	 * Attachment number of the attachment that should be downloaded. For normal attachments this will contain
26
	 * a single element array with numeric value as sequential attachment number, for attachments that are not saved
27
	 * in AttachmentTable of MAPIMessage yet (recently uploaded attachments) this will give single element array
28
	 * having value as a string in form of 'filename randomstring'. When accessing embedded messages this array can contain
29
	 * multiple elements indicating attachment numbers at each level, So value [0, 1] will indicate we want to download
30
	 * second attachment of first embedded message.
31
	 */
32
	private $attachNum;
33
34
	/**
35
	 * Attachment Content Id is used to download inline images of the MAPIMessage, When requesting inline images only
36
	 * content id is passed but if we are accessing inline image from embedded message then besides content id,
37
	 * attachment number is also passed to access embedded message.
38
	 */
39
	private $attachCid;
40
41
	/**
42
	 * A string that will be initialized with grommunio Web-specific and common-for-all file name for ZIP file.
43
	 */
44
	private $zipFileName;
45
46
	/**
47
	 * A random string that will be generated with every MAPIMessage instance to uniquely identify attachments that
48
	 * belongs to this MAPIMessage, this is mainly used to get recently uploaded attachments for MAPIMessage.
49
	 */
50
	private $dialogAttachments;
51
52
	/**
53
	 * A boolean value, set to false by default, to define if the message, of which the attachments are required to be wrapped in ZIP,
54
	 * is a sub message of other webapp item or not.
55
	 */
56
	private $isSubMessage;
57
58
	/**
59
	 * Entryid of the MAPIFolder to which the given attachment needs to be imported as webapp item.
60
	 */
61
	private $destinationFolderId;
62
63
	/**
64
	 * Resource of the MAPIFolder to which the given attachment needs to be imported as webapp item.
65
	 */
66
	private $destinationFolder;
67
68
	/**
69
	 * A boolean value, set to false by default, to define if the attachment needs to be imported into folder as webapp item.
70
	 */
71
	private $import;
72
73
	/**
74
	 * A boolean value, set to false by default, to define if the embedded attachment needs to be imported into folder.
75
	 */
76
	private $isEmbedded;
77
78
	/**
79
	 * Resource of the shared MAPIStore into which attachments needs to be imported.
80
	 */
81
	private $otherStore;
82
83
	private $messageSubject;
84
85
	/**
86
	 * Constructor.
87
	 */
88
	public function __construct() {
89
		$this->contentDispositionType = 'attachment';
90
		$this->attachNum = [];
91
		$this->attachCid = false;
92
		$this->zipFileName = _('Attachments') . '%s.zip';
93
		$this->messageSubject = '';
94
		$this->isSubMessage = false;
95
		$this->destinationFolderId = false;
96
		$this->destinationFolder = false;
97
		$this->import = false;
98
		$this->isEmbedded = false;
99
		$this->otherStore = false;
100
101
		parent::__construct();
102
	}
103
104
	/**
105
	 * Function will initialize data for this class object. it will also sanitize data
106
	 * for possible XSS attack because data is received in $_GET.
107
	 *
108
	 * @param mixed $data
109
	 */
110
	#[Override]
111
	public function init($data) {
112
		if (isset($data['store'])) {
113
			$this->store = sanitizeValue($data['store'], '', ID_REGEX);
114
		}
115
116
		if (isset($data['entryid'])) {
117
			$this->entryId = sanitizeValue($data['entryid'], '', ID_REGEX);
118
		}
119
120
		if (isset($data['contentDispositionType'])) {
121
			$this->contentDispositionType = sanitizeValue($data['contentDispositionType'], 'attachment', STRING_REGEX);
122
		}
123
124
		if (!empty($data['attachNum'])) {
125
			/**
126
			 * if you are opening an already saved attachment then $data["attachNum"]
127
			 * will contain array of numeric index for that attachment (like 0 or 1 or 2).
128
			 *
129
			 * if you are opening a recently uploaded attachment then $data["attachNum"]
130
			 * will be a one element array and it will contain a string in "filename.randomstring" format
131
			 * like README.txtu6K6AH
132
			 */
133
			foreach ($data['attachNum'] as $attachNum) {
134
				$num = sanitizeValue($attachNum, false, NUMERIC_REGEX);
135
136
				if ($num === false) {
137
					// string is passed in attachNum so get it
138
					$num = sanitizeValue($attachNum, '', FILENAME_REGEX);
139
140
					if (!empty($num)) {
141
						array_push($this->attachNum, $num);
142
					}
143
				}
144
				else {
145
					array_push($this->attachNum, (int) $num);
146
				}
147
			}
148
		}
149
150
		if (isset($data['attachCid'])) {
151
			$this->attachCid = rawurldecode($data['attachCid']);
152
		}
153
154
		if (isset($data['AllAsZip'])) {
155
			$this->allAsZip = sanitizeValue($data['AllAsZip'], '', STRING_REGEX);
156
		}
157
158
		if (isset($data['subject'])) {
159
			// Remove characters that we cannot use in a filename
160
			$data['subject'] = preg_replace('/[^a-z0-9 ()]/mi', '_', $data['subject']);
161
			$this->messageSubject = sanitizeValue($data['subject'], '', FILENAME_REGEX);
162
		}
163
164
		if ($this->allAsZip && isset($data['isSubMessage'])) {
165
			$this->isSubMessage = sanitizeValue($data['isSubMessage'], '', STRING_REGEX);
166
		}
167
168
		if (isset($data['dialog_attachments'])) {
169
			$this->dialogAttachments = sanitizeValue($data['dialog_attachments'], '', STRING_REGEX);
170
		}
171
172
		if ($this->store && $this->entryId) {
173
			$this->store = $GLOBALS['mapisession']->openMessageStore(hex2bin((string) $this->store));
174
			$this->message = mapi_msgstore_openentry($this->store, hex2bin((string) $this->entryId));
175
176
			// Decode smime signed messages on this message
177
			parse_smime($this->store, $this->message);
178
		}
179
180
		if (isset($data['destination_folder'])) {
181
			$this->destinationFolderId = sanitizeValue($data['destination_folder'], '', ID_REGEX);
182
183
			if ($this->destinationFolder === false) {
184
				try {
185
					$this->destinationFolder = mapi_msgstore_openentry($this->store, hex2bin((string) $this->destinationFolderId));
186
				}
187
				catch (Exception) {
188
					// Try to find the folder from shared stores in case if it is not found in current user's store
189
					$this->otherStore = $GLOBALS['operations']->getOtherStoreFromEntryid($this->destinationFolderId);
190
					if ($this->otherStore !== false) {
191
						$this->destinationFolder = mapi_msgstore_openentry($this->otherStore, hex2bin((string) $this->destinationFolderId));
192
					}
193
					else {
194
						$this->destinationFolder = mapi_msgstore_openentry($GLOBALS["mapisession"]->getPublicMessageStore(), hex2bin((string) $this->destinationFolderId));
195
						if (!$this->destinationFolder) {
196
							throw new ZarafaException(_("Destination folder not found."));
197
						}
198
					}
199
				}
200
			}
201
		}
202
203
		if (isset($data['import'])) {
204
			$this->import = sanitizeValue($data['import'], '', STRING_REGEX);
205
		}
206
207
		if (isset($data['is_embedded'])) {
208
			$this->isEmbedded = sanitizeValue($data['is_embedded'], '', STRING_REGEX);
209
		}
210
	}
211
212
	/**
213
	 * Returns inline image attachment based on specified attachCid, To get inline image attachment
214
	 * we need to compare passed attachCid with PR_ATTACH_CONTENT_ID, PR_ATTACH_CONTENT_LOCATION or
215
	 * PR_ATTACH_FILENAME and if that matches then we can get that attachment.
216
	 *
217
	 * @param MAPIAttach $attachment (optional) embedded message attachment from where we need to get the inline image
0 ignored issues
show
The type MAPIAttach was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
218
	 *
219
	 * @return MAPIAttach attachment that is requested and will be sent to client
220
	 */
221
	public function getAttachmentByAttachCid($attachment = false) {
222
		// If the inline image was in a submessage, we have to open that first
223
		if ($attachment !== false) {
224
			$this->message = mapi_attach_openobj($attachment);
225
		}
226
227
		/**
228
		 * restriction to find inline image attachment with matching cid passed.
229
		 */
230
		$restriction = [RES_OR,
231
			[
232
				[RES_CONTENT,
233
					[
234
						FUZZYLEVEL => FL_FULLSTRING | FL_IGNORECASE,
235
						ULPROPTAG => PR_ATTACH_CONTENT_ID,
236
						VALUE => [PR_ATTACH_CONTENT_ID => $this->attachCid],
237
					],
238
				],
239
				[RES_CONTENT,
240
					[
241
						FUZZYLEVEL => FL_FULLSTRING | FL_IGNORECASE,
242
						ULPROPTAG => PR_ATTACH_CONTENT_LOCATION,
243
						VALUE => [PR_ATTACH_CONTENT_LOCATION => $this->attachCid],
244
					],
245
				],
246
				[RES_CONTENT,
247
					[
248
						FUZZYLEVEL => FL_FULLSTRING | FL_IGNORECASE,
249
						ULPROPTAG => PR_ATTACH_FILENAME,
250
						VALUE => [PR_ATTACH_FILENAME => $this->attachCid],
251
					],
252
				],
253
			],
254
		];
255
256
		// Get the attachment table
257
		$attachTable = mapi_message_getattachmenttable($this->message);
258
		mapi_table_restrict($attachTable, $restriction, TBL_BATCH);
259
		$attachments = mapi_table_queryallrows($attachTable, [PR_ATTACH_NUM]);
260
261
		if (count($attachments) > 0) {
262
			// there should be only one attachment
263
			$attachment = mapi_message_openattach($this->message, $attachments[0][PR_ATTACH_NUM]);
264
		}
265
266
		return $attachment;
267
	}
268
269
	/**
270
	 * Returns attachment based on specified attachNum, additionally it will also get embedded message
271
	 * if we want to get the inline image attachment.
272
	 *
273
	 * @return MAPIAttach embedded message attachment or attachment that is requested
274
	 */
275
	public function getAttachmentByAttachNum() {
276
		$attachment = false;
0 ignored issues
show
The assignment to $attachment is dead and can be removed.
Loading history...
277
278
		$len = count($this->attachNum);
279
280
		// Loop through the attachNums, message in message in message ...
281
		for ($index = 0; $index < $len - 1; ++$index) {
282
			// Open the attachment
283
			$tempattach = mapi_message_openattach($this->message, $this->attachNum[$index]);
284
			if ($tempattach) {
285
				// Open the object in the attachment
286
				$this->message = mapi_attach_openobj($tempattach);
287
			}
288
		}
289
290
		// open the attachment
291
		return mapi_message_openattach($this->message, $this->attachNum[$len - 1]);
292
	}
293
294
	/**
295
	 * Function will set the first and last bytes of a range, with a range
296
	 * specified as string and size of the attachment.
297
	 *
298
	 * If $first is greater than $last, the request returns 416 (not satisfiable)
299
	 * If no end of range is specified or larger than the length, $last is set as end
300
	 * If no beginning of range is specified, get last x bytes of attachment
301
	 *
302
	 * @param mixed $range
303
	 * @param mixed $filesize
304
	 * @param mixed $first
305
	 * @param mixed $last
306
	 */
307
	public function downloadSetRange($range, $filesize, &$first, &$last) {
308
		$dash = strpos((string) $range, '-');
309
		$first = trim(substr((string) $range, 0, $dash));
310
		$last = trim(substr((string) $range, $dash + 1));
311
312
		if ($first == '') {
313
			// suffix byte range: gets last x bytes
314
			$suffix = $last;
315
			$last = $filesize - 1;
316
			$first = $filesize - $suffix;
317
			if ($first < 0) {
318
				$first = 0;
319
			}
320
		}
321
		elseif ($last == '' || $last > $filesize - 1) {
322
			$last = $filesize - 1;
323
		}
324
325
		if ($first > $last) {
326
			http_response_code(416);
327
			header("Status: 416 Requested range not satisfiable");
328
			header("Content-Range: */{$filesize}");
329
		}
330
	}
331
332
	/**
333
	 * Function will output $bytes of attachment stream with $buffer_size read ahead.
334
	 *
335
	 * @param mixed $stream
336
	 * @param mixed $bytes
337
	 * @param mixed $buffer_size
338
	 */
339
	public function downloadBufferedRead($stream, $bytes, $buffer_size = 1024) {
340
		$bytes_left = $bytes;
341
		while ($bytes_left > 0) {
342
			$bytes_to_read = min($buffer_size, $bytes_left);
343
			$bytes_left -= $bytes_to_read;
344
			$contents = mapi_stream_read($stream, $bytes_to_read);
345
			echo $contents;
346
			flush();
347
		}
348
	}
349
350
	/**
351
	 * Function will open passed attachment and generate response for that attachment to send it to client.
352
	 * This should only be used to download attachment that is already saved in MAPIMessage.
353
	 *
354
	 * @param MAPIAttach $attachment attachment which will be dumped to client side
355
	 * @param bool       $inline     inline attachment or not
356
	 */
357
	public function downloadSavedAttachment($attachment, $inline = false) {
358
		// Check if the attachment is opened
359
		if ($attachment) {
0 ignored issues
show
$attachment is of type MAPIAttach, thus it always evaluated to true.
Loading history...
360
			// Get the props of the attachment
361
			$props = mapi_attach_getprops($attachment, [PR_ATTACH_FILENAME, PR_ATTACH_LONG_FILENAME, PR_ATTACH_MIME_TAG, PR_DISPLAY_NAME, PR_ATTACH_METHOD, PR_ATTACH_CONTENT_ID]);
362
			// Content Type
363
			$contentType = 'application/octet-stream';
364
			// Filename
365
			$filename = 'ERROR';
366
367
			// Set filename
368
			if ($inline) {
369
				/*
370
				 * Inline attachments are set to "inline.txt"
371
				 * by e.g. KGWC (but not Gromox), see
372
				 * inetmapi/VMIMEToMAPI.cpp and search for
373
				 * inline.txt. KGWC would have to extract the
374
				 * alt/title tag from the img tag when converting
375
				 * it to MAPI. Since it does not handle this,
376
				 * set the filename to CONTENT_ID plus mime tag.
377
				 */
378
				$tags = explode('/', (string) $props[PR_ATTACH_MIME_TAG]);
379
				// IE 11 is weird, when a user renames the file it's not saved as in image, when
380
				// the filename is "test.jpeg", but it works when it's "test.jpg".
381
				$filename = $props[PR_ATTACH_CONTENT_ID] . '.' . str_replace('jpeg', 'jpg', $tags[1]);
382
			}
383
			elseif (isset($props[PR_ATTACH_LONG_FILENAME])) {
384
				$filename = $props[PR_ATTACH_LONG_FILENAME];
385
			}
386
			elseif (isset($props[PR_ATTACH_FILENAME])) {
387
				$filename = $props[PR_ATTACH_FILENAME];
388
			}
389
			elseif (isset($props[PR_DISPLAY_NAME])) {
390
				$filename = $props[PR_DISPLAY_NAME];
391
			}
392
393
			// Set content type if available, otherwise it will be default to application/octet-stream
394
			if (isset($props[PR_ATTACH_MIME_TAG])) {
395
				$contentType = $props[PR_ATTACH_MIME_TAG];
396
			}
397
398
			// Set the headers
399
			header('Pragma: public');
400
			header('Expires: 0'); // set expiration time
401
			header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
402
			header('Content-Disposition: ' . $this->contentDispositionType . '; filename="' . addslashes(browserDependingHTTPHeaderEncode($filename)) . '"');
403
			header('Content-Type: ' . $contentType);
404
			header('Content-Transfer-Encoding: binary');
405
406
			// Open a stream to get the attachment data
407
			$stream = mapi_openproperty($attachment, PR_ATTACH_DATA_BIN, IID_IStream, 0, 0);
408
			$stat = mapi_stream_stat($stream);
409
410
			$bodyoffset = 0;
411
			$ranges = null;
412
413
			$bodysize = $stat['cb'];
414
415
			if ($_SERVER['REQUEST_METHOD'] == 'GET' && isset($_SERVER['HTTP_RANGE']) && $range = stristr(trim((string) $_SERVER['HTTP_RANGE']), 'bytes=')) {
416
				$range = substr($range, 6);
417
				$boundary = bin2hex(random_bytes(48));
418
				$ranges = explode(',', $range);
419
			}
420
421
			if ($ranges && count($ranges)) {
422
				http_response_code(206);
423
				header("Accept-Ranges: bytes");
424
				if (count($ranges) > 1) {
425
					// More than one range specified
426
					$content_length = 0;
427
					foreach ($ranges as $range) {
428
						$this->downloadSetRange($range, $stat['cb'], $first, $last);
429
						$content_length += strlen("\r\n--{$boundary}\r\n");
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $boundary does not seem to be defined for all execution paths leading up to this point.
Loading history...
430
						$content_length += strlen("Content-Type: {$contentType}\r\n");
431
						$content_length += strlen("Content-Range: bytes {$first}-{$last}/{$bodysize}\r\n\r\n");
432
						$content_length += $last - $first + 1;
433
					}
434
					$content_length += strlen("\r\n--{$boundary}--\r\n");
435
436
					// Header output
437
					header("Content-Length: {$content_length}");
438
					header("Content-Type: multipart/x-byteranges; boundary={$boundary}");
439
440
					// Content output
441
					foreach ($ranges as $range) {
442
						$this->downloadSetRange($range, $stat['cb'], $first, $last);
443
						echo "\r\n--{$boundary}\r\n";
444
						echo "Content-Type: {$contentType}\r\n";
445
						echo "Content-Range: bytes {$first}-{$last}/{$bodysize}\r\n\r\n";
446
						mapi_stream_seek($stream, $first + $bodyoffset);
447
						$this->downloadBufferedRead($stream, $last - $first + 1);
448
					}
449
					echo "\r\n--{$boundary}--\r\n";
450
				}
451
				else {
452
					// Single range specified
453
					$range = $ranges[0];
454
					$this->downloadSetRange($range, $bodysize, $first, $last);
455
					header("Content-Length: " . ($last - $first + 1));
456
					header("Content-Range: bytes {$first}-{$last}/{$bodysize}");
457
					header("Content-Type: {$contentType}");
458
					mapi_stream_seek($stream, $first + $bodyoffset);
459
					$this->downloadBufferedRead($stream, $last - $first + 1);
460
				}
461
			}
462
			else {
463
				// File length
464
				header('Content-Length: ' . $stat['cb']);
465
466
				// Read the attachment content from the stream
467
				$body = '';
468
				for ($i = 0; $i < $stat['cb']; $i += BLOCK_SIZE) {
469
					$body .= mapi_stream_read($stream, BLOCK_SIZE);
470
				}
471
472
				echo $body;
473
			}
474
		}
475
	}
476
477
	/**
478
	 * Helper function to configure header information which is required to send response as a ZIP archive
479
	 * containing all the attachments.
480
	 *
481
	 * @param string $randomZipName a random zip archive name
482
	 */
483
	public function sendZipResponse($randomZipName) {
484
		$subject = isset($this->messageSubject) ? ' ' . $this->messageSubject : '';
485
486
		// Set the headers
487
		header('Pragma: public');
488
		header('Expires: 0'); // set expiration time
489
		header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
490
		header('Content-Disposition: ' . $this->contentDispositionType . '; filename="' . addslashes(browserDependingHTTPHeaderEncode(sprintf($this->zipFileName, $subject))) . '"');
491
		header('Content-Transfer-Encoding: binary');
492
		header('Content-Type:  application/zip');
493
		header('Content-Length: ' . filesize($randomZipName));
494
495
		// Send the actual response as ZIP file
496
		readfile($randomZipName);
497
498
		// Remove the zip file to avoid unnecessary disk-space consumption
499
		unlink($randomZipName);
500
	}
501
502
	/**
503
	 * Function will open all attachments of message and prepare a ZIP file response for that attachment to send it to client.
504
	 * This should only be used to download attachment that is already saved in MAPIMessage.
505
	 *
506
	 * @param AttachmentState $attachment_state object of AttachmentState class
507
	 * @param ZipArchive      $zip              zipArchive object
508
	 */
509
	public function addAttachmentsToZipArchive($attachment_state, $zip) {
510
		// Get all the attachments from message
511
		$attachmentTable = mapi_message_getattachmenttable($this->message);
512
		$attachments = mapi_table_queryallrows($attachmentTable, [PR_ATTACH_NUM, PR_ATTACH_METHOD]);
513
514
		foreach ($attachments as $attachmentRow) {
515
			if ($attachmentRow[PR_ATTACH_METHOD] !== ATTACH_EMBEDDED_MSG) {
516
				$attachment = mapi_message_openattach($this->message, $attachmentRow[PR_ATTACH_NUM]);
517
518
				// Prevent inclusion of inline attachments and contact photos into ZIP
519
				if (!$attachment_state->isInlineAttachment($attachment) && !$attachment_state->isContactPhoto($attachment)) {
520
					$props = mapi_attach_getprops($attachment, [PR_ATTACH_LONG_FILENAME]);
521
522
					// Open a stream to get the attachment data
523
					$stream = mapi_openproperty($attachment, PR_ATTACH_DATA_BIN, IID_IStream, 0, 0);
524
					$stat = mapi_stream_stat($stream);
525
526
					// Get the stream
527
					$datastring = '';
528
					for ($i = 0; $i < $stat['cb']; $i += BLOCK_SIZE) {
529
						$datastring .= mapi_stream_read($stream, BLOCK_SIZE);
530
					}
531
532
					// Add file into zip by stream
533
					$fileDownloadName = $this->handleDuplicateFileNames($props[PR_ATTACH_LONG_FILENAME]);
534
					$zip->addFromString($fileDownloadName, $datastring);
535
				}
536
			}
537
		}
538
539
		// Go for adding unsaved attachments in ZIP, if any.
540
		// This situation arise while user upload attachments in draft.
541
		$attachmentFiles = $attachment_state->getAttachmentFiles($this->dialogAttachments);
542
		if ($attachmentFiles) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $attachmentFiles of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
543
			$this->addUnsavedAttachmentsToZipArchive($attachment_state, $zip);
544
		}
545
	}
546
547
	/**
548
	 * Function will send attachment data to client side.
549
	 * This should only be used to download attachment that is recently uploaded and not saved in MAPIMessage.
550
	 */
551
	public function downloadUnsavedAttachment() {
552
		// return recently uploaded file
553
		$attachment_state = new AttachmentState();
554
		$attachment_state->open();
555
556
		// there will be only one value in attachNum so directly access 0th element of it
557
		$tmpname = $attachment_state->getAttachmentPath($this->attachNum[0]);
558
		$fileinfo = $attachment_state->getAttachmentFile($this->dialogAttachments, $this->attachNum[0]);
559
560
		// Check if the file still exists
561
		if (is_file($tmpname)) {
562
			// Set the headers
563
			header('Pragma: public');
564
			header('Expires: 0'); // set expiration time
565
			header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
566
			header('Content-Disposition: ' . $this->contentDispositionType . '; filename="' . addslashes(browserDependingHTTPHeaderEncode($fileinfo['name'])) . '"');
567
			header('Content-Transfer-Encoding: binary');
568
			header('Content-Type: application/octet-stream');
569
			header('Content-Length: ' . filesize($tmpname));
570
571
			// Open the uploaded file and print it
572
			$file = fopen($tmpname, 'r');
573
			fpassthru($file);
574
			fclose($file);
575
		}
576
		elseif ($fileinfo['sourcetype'] === 'icsfile') {
577
			// When "Send to" option used with calendar item. which create the new mail with
578
			// ics file as an attachment and now user try to download the ics attachment before saving
579
			// mail at that time this code is used to download the ics file successfully.
580
			$messageStore = $GLOBALS['mapisession']->openMessageStore(hex2bin((string) $fileinfo['store_entryid']));
581
			$message = mapi_msgstore_openentry($messageStore, hex2bin((string) $fileinfo['entryid']));
582
583
			// Get address book for current session
584
			$addrBook = $GLOBALS['mapisession']->getAddressbook();
585
586
			// get message properties.
587
			$messageProps = mapi_getprops($message, [PR_SUBJECT]);
588
589
			// Read the appointment as RFC2445-formatted ics stream.
590
			$appointmentStream = mapi_mapitoical($GLOBALS['mapisession']->getSession(), $addrBook, $message, []);
591
592
			$filename = (!empty($messageProps[PR_SUBJECT])) ? $messageProps[PR_SUBJECT] : _('Untitled');
593
			$filename .= '.ics';
594
			// Set the headers
595
			header('Pragma: public');
596
			header('Expires: 0'); // set expiration time
597
			header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
598
			header('Content-Transfer-Encoding: binary');
599
600
			// Set Content Disposition header
601
			header('Content-Disposition: ' . $this->contentDispositionType . '; filename="' . addslashes(browserDependingHTTPHeaderEncode($filename)) . '"');
602
			// Set content type header
603
			header('Content-Type: application/octet-stream');
604
605
			// Set the file length
606
			header('Content-Length: ' . strlen($appointmentStream));
607
608
			$split = str_split($appointmentStream, BLOCK_SIZE);
609
			foreach ($split as $s) {
610
				echo $s;
611
			}
612
		}
613
		$attachment_state->close();
614
	}
615
616
	/**
617
	 * Function will send all the attachments to client side wrapped in a ZIP file.
618
	 * This should only be used to download all the attachments that are recently uploaded and not saved in MAPIMessage.
619
	 *
620
	 * @param AttachmentState $attachment_state object of AttachmentState class
621
	 * @param ZipArchive      $zip              zipArchive object
622
	 */
623
	public function addUnsavedAttachmentsToZipArchive($attachment_state, $zip) {
624
		// Get recently uploaded attachment files
625
		$attachmentFiles = $attachment_state->getAttachmentFiles($this->dialogAttachments);
626
627
		foreach ($attachmentFiles as $fileName => $fileInfo) {
628
			$filePath = $attachment_state->getAttachmentPath($fileName);
629
			// Avoid including contact photo and embedded messages in ZIP
630
			if ($fileInfo['sourcetype'] !== 'embedded' && $fileInfo['sourcetype'] !== 'contactphoto') {
631
				$fileDownloadName = $this->handleDuplicateFileNames($fileInfo['name']);
632
				$zip->addFile($filePath, $fileDownloadName);
633
			}
634
		}
635
	}
636
637
	/**
638
	 * Function will get the attachment and import it to the given MAPIFolder as webapp item.
639
	 */
640
	public function importAttachment() {
641
		$addrBook = $GLOBALS['mapisession']->getAddressbook();
642
643
		$newMessage = mapi_folder_createmessage($this->destinationFolder);
644
		$attachment = $this->getAttachmentByAttachNum();
645
		$attachmentProps = mapi_attach_getprops($attachment, [PR_ATTACH_LONG_FILENAME]);
646
		$attachmentStream = streamProperty($attachment, PR_ATTACH_DATA_BIN);
647
648
		switch (pathinfo((string) $attachmentProps[PR_ATTACH_LONG_FILENAME], PATHINFO_EXTENSION)) {
649
			case 'eml':
650
				if (isBrokenEml($attachmentStream)) {
651
					throw new ZarafaException(_("Eml is corrupted"));
652
				}
653
654
				try {
655
					// Convert an RFC822-formatted e-mail to a MAPI Message
656
					$ok = mapi_inetmapi_imtomapi($GLOBALS['mapisession']->getSession(), $this->store, $addrBook, $newMessage, $attachmentStream, []);
657
				}
658
				catch (Exception) {
659
					throw new ZarafaException(_("The eml Attachment is not imported successfully"));
660
				}
661
662
				break;
663
664
			case 'vcf':
665
				try {
666
					// Convert an RFC6350-formatted vCard to a MAPI Contact
667
					$ok = mapi_vcftomapi($GLOBALS['mapisession']->getSession(), $this->store, $newMessage, $attachmentStream);
668
				}
669
				catch (Exception) {
670
					throw new ZarafaException(_("The vcf attachment is not imported successfully"));
671
				}
672
				break;
673
674
			case 'vcs':
675
			case 'ics':
676
				try {
677
					// Convert vCalendar 1.0 or iCalendar to a MAPI Appointment
678
					$ok = mapi_icaltomapi($GLOBALS['mapisession']->getSession(), $this->store, $addrBook, $newMessage, $attachmentStream, false);
679
				}
680
				catch (Exception $e) {
681
					$destinationFolderProps = mapi_getprops($this->destinationFolder, [PR_DISPLAY_NAME, PR_MDB_PROVIDER]);
682
					$fullyQualifiedFolderName = $destinationFolderProps[PR_DISPLAY_NAME];
683
					if ($destinationFolderProps[PR_MDB_PROVIDER] === ZARAFA_STORE_PUBLIC_GUID) {
684
						$publicStore = $GLOBALS["mapisession"]->getPublicMessageStore();
685
						$publicStoreName = mapi_getprops($publicStore, [PR_DISPLAY_NAME]);
686
						$fullyQualifiedFolderName .= " - " . $publicStoreName[PR_DISPLAY_NAME];
687
					}
688
					elseif ($destinationFolderProps[PR_MDB_PROVIDER] === ZARAFA_STORE_DELEGATE_GUID) {
689
						$sharedStoreOwnerName = mapi_getprops($this->otherStore, [PR_MAILBOX_OWNER_NAME]);
690
						$fullyQualifiedFolderName .= " - " . $sharedStoreOwnerName[PR_MAILBOX_OWNER_NAME];
691
					}
692
693
					$message = sprintf(_("Unable to import '%s' to '%s'. "), $attachmentProps[PR_ATTACH_LONG_FILENAME], $fullyQualifiedFolderName);
694
					if ($e->getCode() === MAPI_E_TABLE_EMPTY) {
695
						$message .= _("There is no appointment found in this file.");
696
					}
697
					elseif ($e->getCode() === MAPI_E_CORRUPT_DATA) {
698
						$message .= _("The file is corrupt.");
699
					}
700
					elseif ($e->getCode() === MAPI_E_INVALID_PARAMETER) {
701
						$message .= _("The file is invalid.");
702
					}
703
					else {
704
						$message = sprintf(_("Unable to import '%s'. "), $attachmentProps[PR_ATTACH_LONG_FILENAME]) . $e->getMessage();
705
					}
706
707
					$e = new ZarafaException($message);
708
					$e->setTitle(_("Import error"));
709
710
					throw $e;
711
				}
712
				break;
713
		}
714
715
		if ($ok === true) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $ok does not seem to be defined for all execution paths leading up to this point.
Loading history...
716
			mapi_savechanges($newMessage);
717
718
			// Check that record is not appointment record. we have to only convert the
719
			// Meeting request record to appointment record.
720
			$newMessageProps = mapi_getprops($newMessage, [PR_MESSAGE_CLASS]);
721
			if (isset($newMessageProps[PR_MESSAGE_CLASS]) && $newMessageProps[PR_MESSAGE_CLASS] !== 'IPM.Appointment') {
722
				// Convert the Meeting request record to proper appointment record so we can
723
				// properly show the appointment in calendar.
724
				$req = new Meetingrequest($this->store, $newMessage, $GLOBALS['mapisession']->getSession(), ENABLE_DIRECT_BOOKING);
0 ignored issues
show
The type Meetingrequest was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
725
				$req->doAccept(true, false, false, false, false, false, false, false, false, true);
726
			}
727
			$storeProps = mapi_getprops($this->store, [PR_ENTRYID]);
728
			$destinationFolderProps = mapi_getprops($this->destinationFolder, [PR_PARENT_ENTRYID, PR_CONTENT_UNREAD]);
729
730
			$return = [
731
				// 'success' property is needed for Extjs Ext.form.Action.Submit#success handler
732
				'success' => true,
733
				'zarafa' => [
734
					sanitizeGetValue('module', '', STRING_REGEX) => [
735
						sanitizeGetValue('moduleid', '', STRING_REGEX) => [
736
							'update' => [
737
								'success' => true,
738
							],
739
						],
740
					],
741
				],
742
			];
743
744
			// send hierarchy notification only in case of 'eml'
745
			if (pathinfo((string) $attachmentProps[PR_ATTACH_LONG_FILENAME], PATHINFO_EXTENSION) === 'eml') {
746
				$hierarchynotifier = [
747
					'hierarchynotifier1' => [
748
						'folders' => [
749
							'item' => [
750
								0 => [
751
									'entryid' => $this->destinationFolderId,
752
									'parent_entryid' => bin2hex((string) $destinationFolderProps[PR_PARENT_ENTRYID]),
753
									'store_entryid' => bin2hex((string) $storeProps[PR_ENTRYID]),
754
									'props' => [
755
										'content_unread' => $destinationFolderProps[PR_CONTENT_UNREAD] + 1,
756
									],
757
								],
758
							],
759
						],
760
					],
761
				];
762
763
				$return['zarafa']['hierarchynotifier'] = $hierarchynotifier;
764
			}
765
766
			echo json_encode($return);
767
		}
768
		else {
769
			throw new ZarafaException(_("Attachment is not imported successfully"));
770
		}
771
	}
772
773
	/**
774
	 * Function will get the embedded attachment and import it to the given MAPIFolder as webapp item.
775
	 */
776
	public function importEmbeddedAttachment() {
777
		// get message props of sub message
778
		$copyFromMessage = $GLOBALS['operations']->openMessage($this->store, hex2bin((string) $this->entryId), $this->attachNum, true);
779
780
		if (empty($copyFromMessage)) {
781
			throw new ZarafaException(_("Embedded attachment not found."));
782
		}
783
784
		$newMessage = mapi_folder_createmessage($this->destinationFolder);
785
786
		// Copy the entire message
787
		mapi_copyto($copyFromMessage, [], [], $newMessage);
788
		mapi_savechanges($newMessage);
789
790
		$storeProps = mapi_getprops($this->store, [PR_ENTRYID]);
791
		$destinationFolderProps = mapi_getprops($this->destinationFolder, [PR_PARENT_ENTRYID, PR_CONTENT_UNREAD]);
792
		$return = [
793
			// 'success' property is needed for Extjs Ext.form.Action.Submit#success handler
794
			'success' => true,
795
			'zarafa' => [
796
				sanitizeGetValue('module', '', STRING_REGEX) => [
797
					sanitizeGetValue('moduleid', '', STRING_REGEX) => [
798
						'update' => [
799
							'success' => true,
800
						],
801
					],
802
				],
803
				'hierarchynotifier' => [
804
					'hierarchynotifier1' => [
805
						'folders' => [
806
							'item' => [
807
								0 => [
808
									'entryid' => $this->destinationFolderId,
809
									'parent_entryid' => bin2hex((string) $destinationFolderProps[PR_PARENT_ENTRYID]),
810
									'store_entryid' => bin2hex((string) $storeProps[PR_ENTRYID]),
811
									'props' => [
812
										'content_unread' => $destinationFolderProps[PR_CONTENT_UNREAD] + 1,
813
									],
814
								],
815
							],
816
						],
817
					],
818
				],
819
			],
820
		];
821
822
		echo json_encode($return);
823
	}
824
825
	/**
826
	 * Check if the attached eml is corrupted or not.
827
	 *
828
	 * @param string $attachment content fetched from PR_ATTACH_DATA_BIN property of an attachment
829
	 *
830
	 * @return true if eml is broken, false otherwise
831
	 */
832
	public function isBroken($attachment) {
833
		// Get header part to process further
834
		$splittedContent = preg_split("/\r?\n\r?\n/", $attachment);
835
836
		// Fetch raw header
837
		if (preg_match_all('/([^:]+): ?.*\n/', $splittedContent[0], $matches)) {
838
			$rawHeaders = $matches[1];
839
		}
840
841
		// Compare if necessary headers are present or not
842
		if (isset($rawHeaders) && in_array('From', $rawHeaders) && in_array('Date', $rawHeaders)) {
843
			return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type true.
Loading history...
844
		}
845
846
		return true;
847
	}
848
849
	/**
850
	 * Generic function to check passed data and decide which type of attachment is requested.
851
	 */
852
	public function download() {
853
		$attachment = false;
854
855
		// Check if all attachments are requested to be downloaded as ZIP
856
		if ($this->allAsZip) {
857
			$attachment_state = new AttachmentState();
858
			$attachment_state->open();
859
860
			// Generate random ZIP file name at default temporary path of PHP
861
			$randomZipName = tempnam(sys_get_temp_dir(), 'zip');
862
863
			// Create an open zip archive.
864
			$zip = new ZipArchive();
865
			$result = $zip->open($randomZipName, ZipArchive::OVERWRITE);
866
867
			if ($result === true) {
868
				// Check if attachments are of saved message.
869
				// Only saved message has the entryid configured.
870
				if ($this->entryId) {
871
					// Check if the requested attachment(s) are of an embedded message
872
					if ($this->isSubMessage) {
873
						// Loop through the attachNums, message in message in message ...
874
						for ($index = 0, $len = count($this->attachNum); $index < $len - 1; ++$index) {
875
							// Open the attachment
876
							$tempattach = mapi_message_openattach($this->message, $this->attachNum[$index]);
877
							if ($tempattach) {
878
								// Open the object in the attachment
879
								$this->message = mapi_attach_openobj($tempattach);
880
							}
881
						}
882
					}
883
					$this->addAttachmentsToZipArchive($attachment_state, $zip);
884
				}
885
				else {
886
					$this->addUnsavedAttachmentsToZipArchive($attachment_state, $zip);
887
				}
888
			}
889
			else {
890
				// Throw exception if ZIP is not created successfully
891
				throw new ZarafaException(_("ZIP is not created successfully"));
892
			}
893
894
			$zip->close();
895
896
			$this->sendZipResponse($randomZipName);
897
			$attachment_state->close();
898
		// check if inline image is requested
899
		}
900
		elseif ($this->attachCid) {
901
			// check if the inline image is in a embedded message
902
			if (count($this->attachNum) > 0) {
903
				// get the embedded message attachment
904
				$attachment = $this->getAttachmentByAttachNum();
905
			}
906
907
			// now get the actual attachment object that should be sent back to client
908
			$attachment = $this->getAttachmentByAttachCid($attachment);
0 ignored issues
show
It seems like $attachment can also be of type false; however, parameter $attachment of DownloadAttachment::getAttachmentByAttachCid() does only seem to accept MAPIAttach, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

908
			$attachment = $this->getAttachmentByAttachCid(/** @scrutinizer ignore-type */ $attachment);
Loading history...
909
910
			// no need to return anything here function will echo all the output
911
			$this->downloadSavedAttachment($attachment, true);
912
		}
913
		elseif (count($this->attachNum) > 0) {
914
			// check if the attachment needs to be imported
915
			if ($this->import) {
916
				if ($this->isEmbedded) {
917
					$this->importEmbeddedAttachment();
918
				}
919
				else {
920
					$this->importAttachment();
921
				}
922
923
				return;
924
			}
925
926
			// check if temporary unsaved attachment is requested
927
			if (is_string($this->attachNum[0])) {
928
				$this->downloadUnsavedAttachment();
929
			}
930
			else {
931
				// normal saved attachment is requested, so get it
932
				$attachment = $this->getAttachmentByAttachNum();
933
934
				if ($attachment === false) {
0 ignored issues
show
The condition $attachment === false is always false.
Loading history...
935
					// something terrible happened and we can't continue
936
					return;
937
				}
938
939
				// no need to return anything here function will echo all the output
940
				$this->downloadSavedAttachment($attachment);
941
			}
942
		}
943
		else {
944
			throw new ZarafaException(_("Attachments can not be downloaded"));
945
		}
946
	}
947
948
	/**
949
	 * Function will encode all the necessary information about the exception
950
	 * into JSON format and send the response back to client.
951
	 *
952
	 * @param object $exception exception object
953
	 */
954
	#[Override]
955
	public function handleSaveMessageException($exception) {
956
		$return = [];
957
958
		// MAPI_E_NOT_FOUND exception contains generalize exception message.
959
		// Set proper exception message as display message should be user understandable.
960
		if ($exception->getCode() == MAPI_E_NOT_FOUND) {
961
			$exception->setDisplayMessage(_('Could not find attachment.'));
962
		}
963
964
		// Set the headers
965
		header('Expires: 0'); // set expiration time
966
		header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
967
968
		// Set Content Disposition header
969
		header('Content-Disposition: inline');
970
		// Set content type header
971
		header('Content-Type: text/plain');
972
973
		// prepare exception response according to exception class
974
		if ($exception instanceof MAPIException) {
975
			$return = [
976
				'success' => false,
977
				'zarafa' => [
978
					'error' => [
979
						'type' => ERROR_MAPI,
980
						'info' => [
981
							'hresult' => $exception->getCode(),
982
							'hresult_name' => get_mapi_error_name($exception->getCode()),
983
							'file' => $exception->getFileLine(),
984
							'display_message' => $exception->getDisplayMessage(),
985
						],
986
					],
987
				],
988
			];
989
		}
990
		elseif ($exception instanceof ZarafaException) {
991
			$return = [
992
				'success' => false,
993
				'zarafa' => [
994
					'error' => [
995
						'type' => ERROR_ZARAFA,
996
						'info' => [
997
							'file' => $exception->getFileLine(),
998
							'display_message' => $exception->getDisplayMessage(),
999
							'original_message' => $exception->getMessage(),
1000
						],
1001
					],
1002
				],
1003
			];
1004
		}
1005
		elseif ($exception instanceof BaseException) {
1006
			$return = [
1007
				'success' => false,
1008
				'zarafa' => [
1009
					'error' => [
1010
						'type' => ERROR_GENERAL,
1011
						'info' => [
1012
							'file' => $exception->getFileLine(),
1013
							'display_message' => $exception->getDisplayMessage(),
1014
							'original_message' => $exception->getMessage(),
1015
						],
1016
					],
1017
				],
1018
			];
1019
		}
1020
		else {
1021
			$return = [
1022
				'success' => false,
1023
				'zarafa' => [
1024
					'error' => [
1025
						'type' => ERROR_GENERAL,
1026
						'info' => [
1027
							'display_message' => _('Operation failed'),
1028
							'original_message' => $exception->getMessage(),
1029
						],
1030
					],
1031
				],
1032
			];
1033
		}
1034
		echo json_encode($return);
1035
	}
1036
}
1037
1038
// create instance of class to download attachment
1039
$attachInstance = new DownloadAttachment();
1040
1041
try {
1042
	// initialize variables
1043
	$attachInstance->init($_GET);
1044
1045
	// download attachment
1046
	$attachInstance->download();
1047
}
1048
catch (Exception $e) {
1049
	$attachInstance->handleSaveMessageException($e);
1050
}
1051