Issues (1061)

Sources/ShowAttachments.php (16 issues)

1
<?php
2
3
/**
4
 * This file handles avatar and attachment requests. The whole point of this file is to reduce the loaded stuff to show an image.
5
 *
6
 * Simple Machines Forum (SMF)
7
 *
8
 * @package SMF
9
 * @author Simple Machines https://www.simplemachines.org
10
 * @copyright 2020 Simple Machines and individual contributors
11
 * @license https://www.simplemachines.org/about/smf/license.php BSD
12
 *
13
 * @version 2.1 RC2
14
 */
15
16
if (!defined('SMF'))
17
	die('No direct access...');
18
19
/**
20
 * Downloads an avatar or attachment based on $_GET['attach'], and increments the download count.
21
 * It requires the view_attachments permission.
22
 * It disables the session parser, and clears any previous output.
23
 * It depends on the attachmentUploadDir setting being correct.
24
 * It is accessed via the query string ?action=dlattach.
25
 * Views to attachments do not increase hits and are not logged in the "Who's Online" log.
26
 */
27
function showAttachment()
28
{
29
	global $smcFunc, $modSettings, $maintenance, $context, $txt;
30
31
	// Some defaults that we need.
32
	$context['character_set'] = empty($modSettings['global_character_set']) ? (empty($txt['lang_character_set']) ? 'ISO-8859-1' : $txt['lang_character_set']) : $modSettings['global_character_set'];
33
	$context['utf8'] = $context['character_set'] === 'UTF-8';
34
35
	// An early hook to set up global vars, clean cache and other early process.
36
	call_integration_hook('integrate_pre_download_request');
37
38
	// This is done to clear any output that was made before now.
39
	ob_end_clean();
40
41
	if (!empty($modSettings['enableCompressedOutput']) && !headers_sent() && ob_get_length() == 0)
42
	{
43
		if (@ini_get('zlib.output_compression') == '1' || @ini_get('output_handler') == 'ob_gzhandler')
44
			$modSettings['enableCompressedOutput'] = 0;
45
46
		else
47
			ob_start('ob_gzhandler');
48
	}
49
50
	if (empty($modSettings['enableCompressedOutput']))
51
	{
52
		ob_start();
53
		header('content-encoding: none');
54
	}
55
56
	// Better handling.
57
	$attachId = isset($_REQUEST['attach']) ? (int) $_REQUEST['attach'] : (int) (isset($_REQUEST['id']) ? (int) $_REQUEST['id'] : 0);
58
59
	// We need a valid ID.
60
	if (empty($attachId))
61
	{
62
		send_http_status(404, 'File Not Found');
63
		die('404 File Not Found');
0 ignored issues
show
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
64
	}
65
66
	// A thumbnail has been requested? madness! madness I say!
67
	$preview = isset($_REQUEST['preview']) ? $_REQUEST['preview'] : (isset($_REQUEST['type']) && $_REQUEST['type'] == 'preview' ? $_REQUEST['type'] : 0);
68
	$showThumb = isset($_REQUEST['thumb']) || !empty($preview);
69
	$attachTopic = isset($_REQUEST['topic']) ? (int) $_REQUEST['topic'] : 0;
70
71
	// No access in strict maintenance mode or you don't have permission to see attachments.
72
	if ((!empty($maintenance) && $maintenance == 2) || !allowedTo('view_attachments'))
73
	{
74
		send_http_status(404, 'File Not Found');
75
		die('404 File Not Found');
0 ignored issues
show
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
76
	}
77
78
	// Use cache when possible.
79
	if (($cache = cache_get_data('attachment_lookup_id-' . $attachId)) != null)
0 ignored issues
show
It seems like you are loosely comparing $cache = cache_get_data(...ookup_id-' . $attachId) of type null|string against null; this is ambiguous if the string can be empty. Consider using a strict comparison !== instead.
Loading history...
80
		list ($file, $thumbFile) = $cache;
81
82
	// Get the info from the DB.
83
	if (empty($file) || empty($thumbFile) && !empty($file['id_thumb']))
84
	{
85
		// Do we have a hook wanting to use our attachment system? We use $attachRequest to prevent accidental usage of $request.
86
		$attachRequest = null;
87
		call_integration_hook('integrate_download_request', array(&$attachRequest));
88
		if (!is_null($attachRequest) && $smcFunc['db_is_resource']($attachRequest))
0 ignored issues
show
The condition is_null($attachRequest) is always true.
Loading history...
89
			$request = $attachRequest;
90
		else
91
		{
92
			// Make sure this attachment is on this board and load its info while we are at it.
93
			$request = $smcFunc['db_query']('', '
94
				SELECT
95
					id_folder, filename, file_hash, fileext, id_attach,
96
					id_thumb, attachment_type, mime_type, approved, id_msg
97
				FROM {db_prefix}attachments
98
				WHERE id_attach = {int:attach}' . (!empty($context['preview_message']) ? '
99
					AND a.id_msg != 0' : '') . '
100
				LIMIT 1',
101
				array(
102
					'attach' => $attachId,
103
				)
104
			);
105
		}
106
107
		// No attachment has been found.
108
		if ($smcFunc['db_num_rows']($request) == 0)
109
		{
110
			send_http_status(404, 'File Not Found');
111
			die('404 File Not Found');
0 ignored issues
show
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
112
		}
113
114
		$file = $smcFunc['db_fetch_assoc']($request);
115
		$smcFunc['db_free_result']($request);
116
117
		// If theres a message ID stored, we NEED a topic ID.
118
		if (!empty($file['id_msg']) && empty($attachTopic) && empty($preview))
119
		{
120
			send_http_status(404, 'File Not Found');
121
			die('404 File Not Found');
0 ignored issues
show
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
122
		}
123
124
		// Previews doesn't have this info.
125
		if (empty($preview) && is_resource($attachRequest))
126
		{
127
			$request2 = $smcFunc['db_query']('', '
128
				SELECT a.id_msg
129
				FROM {db_prefix}attachments AS a
130
					INNER JOIN {db_prefix}messages AS m ON (m.id_msg = a.id_msg AND m.id_topic = {int:current_topic})
131
				WHERE {query_see_message_board}
132
					AND a.id_attach = {int:attach}
133
				LIMIT 1',
134
				array(
135
					'attach' => $attachId,
136
					'current_topic' => $attachTopic,
137
				)
138
			);
139
140
			// The provided topic must match the one stored in the DB for this particular attachment, also.
141
			if ($smcFunc['db_num_rows']($request2) == 0)
142
			{
143
				send_http_status(404, 'File Not Found');
144
				die('404 File Not Found');
0 ignored issues
show
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
145
			}
146
147
			$smcFunc['db_free_result']($request2);
148
		}
149
150
		// set filePath and ETag time
151
		$file['filePath'] = getAttachmentFilename($file['filename'], $attachId, $file['id_folder'], false, $file['file_hash']);
152
		// ensure variant attachment compatibility
153
		$filePath = pathinfo($file['filePath']);
154
155
		$file['filePath'] = !file_exists($file['filePath']) && isset($filePath['extension']) ? substr($file['filePath'], 0, -(strlen($filePath['extension']) + 1)) : $file['filePath'];
156
		$file['etag'] = '"' . md5_file($file['filePath']) . '"';
157
158
		// now get the thumbfile!
159
		$thumbFile = array();
160
		if (!empty($file['id_thumb']))
161
		{
162
			$request = $smcFunc['db_query']('', '
163
				SELECT id_folder, filename, file_hash, fileext, id_attach, attachment_type, mime_type, approved, id_member
164
				FROM {db_prefix}attachments
165
				WHERE id_attach = {int:thumb_id}
166
				LIMIT 1',
167
				array(
168
					'thumb_id' => $file['id_thumb'],
169
				)
170
			);
171
172
			$thumbFile = $smcFunc['db_fetch_assoc']($request);
173
			$smcFunc['db_free_result']($request);
174
175
			// Got something! replace the $file var with the thumbnail info.
176
			if ($thumbFile)
177
			{
178
				$attachId = $thumbFile['id_attach'];
179
180
				// set filePath and ETag time
181
				$thumbFile['filePath'] = getAttachmentFilename($thumbFile['filename'], $attachId, $thumbFile['id_folder'], false, $thumbFile['file_hash']);
182
				$thumbFile['etag'] = '"' . md5_file($thumbFile['filePath']) . '"';
183
			}
184
		}
185
186
		// Cache it.
187
		if (!empty($file) || !empty($thumbFile))
188
			cache_put_data('attachment_lookup_id-' . $file['id_attach'], array($file, $thumbFile), mt_rand(850, 900));
189
	}
190
191
	// Replace the normal file with its thumbnail if it has one!
192
	if (!empty($showThumb) && !empty($thumbFile))
193
		$file = $thumbFile;
194
195
	// No point in a nicer message, because this is supposed to be an attachment anyway...
196
	if (!file_exists($file['filePath']))
197
	{
198
		send_http_status(404);
199
		header('content-type: text/plain; charset=' . (empty($context['character_set']) ? 'ISO-8859-1' : $context['character_set']));
200
201
		// We need to die like this *before* we send any anti-caching headers as below.
202
		die('File not found.');
203
	}
204
205
	// If it hasn't been modified since the last time this attachment was retrieved, there's no need to display it again.
206
	if (!empty($_SERVER['HTTP_IF_MODIFIED_SINCE']))
207
	{
208
		list($modified_since) = explode(';', $_SERVER['HTTP_IF_MODIFIED_SINCE']);
209
		if (strtotime($modified_since) >= filemtime($file['filePath']))
210
		{
211
			ob_end_clean();
212
213
			// Answer the question - no, it hasn't been modified ;).
214
			send_http_status(304);
215
			exit;
0 ignored issues
show
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
216
		}
217
	}
218
219
	// Check whether the ETag was sent back, and cache based on that...
220
	$eTag = '"' . substr($_REQUEST['attach'] . $file['filePath'] . filemtime($file['filePath']), 0, 64) . '"';
221
	if (!empty($_SERVER['HTTP_IF_NONE_MATCH']) && strpos($_SERVER['HTTP_IF_NONE_MATCH'], $eTag) !== false)
222
	{
223
		ob_end_clean();
224
225
		send_http_status(304);
226
		exit;
0 ignored issues
show
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
227
	}
228
229
	// If this is a partial download, we need to determine what data range to send
230
	$range = 0;
231
	$size = filesize($file['filePath']);
232
	if (isset($_SERVER['HTTP_RANGE']))
233
	{
234
		list($a, $range) = explode("=", $_SERVER['HTTP_RANGE'], 2);
235
		list($range) = explode(",", $range, 2);
236
		list($range, $range_end) = explode("-", $range);
237
		$range = intval($range);
238
		$range_end = !$range_end ? $size - 1 : intval($range_end);
239
		$new_length = $range_end - $range + 1;
240
	}
241
242
	// Update the download counter (unless it's a thumbnail or resuming an incomplete download).
243
	if ($file['attachment_type'] != 3 && empty($showThumb) && $range === 0)
244
		$smcFunc['db_query']('', '
245
			UPDATE {db_prefix}attachments
246
			SET downloads = downloads + 1
247
			WHERE id_attach = {int:id_attach}',
248
			array(
249
				'id_attach' => $attachId,
250
			)
251
		);
252
253
	// Send the attachment headers.
254
	header('pragma: ');
255
256
	if (!isBrowser('gecko'))
257
		header('content-transfer-encoding: binary');
258
259
	header('expires: ' . gmdate('D, d M Y H:i:s', time() + 525600 * 60) . ' GMT');
260
	header('last-modified: ' . gmdate('D, d M Y H:i:s', filemtime($file['filePath'])) . ' GMT');
261
	header('accept-ranges: bytes');
262
	header('connection: close');
263
	header('etag: ' . $eTag);
264
265
	// Make sure the mime type warrants an inline display.
266
	if (isset($_REQUEST['image']) && !empty($file['mime_type']) && strpos($file['mime_type'], 'image/') !== 0)
267
		unset($_REQUEST['image']);
268
269
	// Does this have a mime type?
270
	elseif (!empty($file['mime_type']) && (isset($_REQUEST['image']) || !in_array($file['fileext'], array('jpg', 'gif', 'jpeg', 'x-ms-bmp', 'png', 'psd', 'tiff', 'iff'))))
271
		header('content-type: ' . strtr($file['mime_type'], array('image/bmp' => 'image/x-ms-bmp')));
272
273
	else
274
	{
275
		header('content-type: ' . (isBrowser('ie') || isBrowser('opera') ? 'application/octetstream' : 'application/octet-stream'));
276
		if (isset($_REQUEST['image']))
277
			unset($_REQUEST['image']);
278
	}
279
280
	// Convert the file to UTF-8, cuz most browsers dig that.
281
	$utf8name = !$context['utf8'] && function_exists('iconv') ? iconv($context['character_set'], 'UTF-8', $file['filename']) : (!$context['utf8'] && function_exists('mb_convert_encoding') ? mb_convert_encoding($file['filename'], 'UTF-8', $context['character_set']) : $file['filename']);
282
283
	// On mobile devices, audio and video should be served inline so the browser can play them.
284
	if (isset($_REQUEST['image']) || (isBrowser('is_mobile') && (strpos($file['mime_type'], 'audio/') !== 0 || strpos($file['mime_type'], 'video/') !== 0)))
285
		$disposition = 'inline';
286
	else
287
		$disposition = 'attachment';
288
289
	// Different browsers like different standards...
290
	if (isBrowser('firefox'))
291
		header('content-disposition: ' . $disposition . '; filename*=UTF-8\'\'' . rawurlencode(preg_replace_callback('~&#(\d{3,8});~', 'fixchar__callback', $utf8name)));
292
293
	elseif (isBrowser('opera'))
294
		header('content-disposition: ' . $disposition . '; filename="' . preg_replace_callback('~&#(\d{3,8});~', 'fixchar__callback', $utf8name) . '"');
295
296
	elseif (isBrowser('ie'))
297
		header('content-disposition: ' . $disposition . '; filename="' . urlencode(preg_replace_callback('~&#(\d{3,8});~', 'fixchar__callback', $utf8name)) . '"');
298
299
	else
300
		header('content-disposition: ' . $disposition . '; filename="' . $utf8name . '"');
301
302
	// If this has an "image extension" - but isn't actually an image - then ensure it isn't cached cause of silly IE.
303
	if (!isset($_REQUEST['image']) && in_array($file['fileext'], array('gif', 'jpg', 'bmp', 'png', 'jpeg', 'tiff')))
304
		header('cache-control: no-cache');
305
306
	else
307
		header('cache-control: max-age=' . (525600 * 60) . ', private');
308
309
	// Multipart and resuming support
310
	if (isset($_SERVER['HTTP_RANGE']))
311
	{
312
		send_http_status(206);
313
		header("content-length: $new_length");
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $new_length does not seem to be defined for all execution paths leading up to this point.
Loading history...
314
		header("content-range: bytes $range-$range_end/$size");
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $range_end does not seem to be defined for all execution paths leading up to this point.
Loading history...
315
	}
316
	else
317
		header("content-length: " . $size);
318
319
	// Try to buy some time...
320
	@set_time_limit(600);
321
322
	// For multipart/resumable downloads, send the requested chunk(s) of the file
323
	if (isset($_SERVER['HTTP_RANGE']))
324
	{
325
		while (@ob_get_level() > 0)
326
			@ob_end_clean();
327
328
		// 40 kilobytes is a good-ish amount
329
		$chunksize = 40 * 1024;
330
		$bytes_sent = 0;
331
332
		$fp = fopen($file['filePath'], 'rb');
333
334
		fseek($fp, $range);
0 ignored issues
show
It seems like $fp can also be of type false; however, parameter $handle of fseek() does only seem to accept resource, 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

334
		fseek(/** @scrutinizer ignore-type */ $fp, $range);
Loading history...
335
336
		while (!feof($fp) && (!connection_aborted()) && ($bytes_sent < $new_length))
0 ignored issues
show
It seems like $fp can also be of type false; however, parameter $handle of feof() does only seem to accept resource, 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

336
		while (!feof(/** @scrutinizer ignore-type */ $fp) && (!connection_aborted()) && ($bytes_sent < $new_length))
Loading history...
337
		{
338
			$buffer = fread($fp, $chunksize);
0 ignored issues
show
It seems like $fp can also be of type false; however, parameter $handle of fread() does only seem to accept resource, 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

338
			$buffer = fread(/** @scrutinizer ignore-type */ $fp, $chunksize);
Loading history...
339
			echo($buffer);
340
			flush();
341
			$bytes_sent += strlen($buffer);
342
		}
343
		fclose($fp);
0 ignored issues
show
It seems like $fp can also be of type false; however, parameter $handle of fclose() does only seem to accept resource, 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

343
		fclose(/** @scrutinizer ignore-type */ $fp);
Loading history...
344
	}
345
346
	// Since we don't do output compression for files this large...
347
	elseif ($size > 4194304)
348
	{
349
		// Forcibly end any output buffering going on.
350
		while (@ob_get_level() > 0)
351
			@ob_end_clean();
352
353
		$fp = fopen($file['filePath'], 'rb');
354
		while (!feof($fp))
355
		{
356
			echo fread($fp, 8192);
357
			flush();
358
		}
359
		fclose($fp);
360
	}
361
362
	// On some of the less-bright hosts, readfile() is disabled.  It's just a faster, more byte safe, version of what's in the if.
363
	elseif (@readfile($file['filePath']) === null)
364
		echo file_get_contents($file['filePath']);
365
366
	die();
0 ignored issues
show
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
367
}
368
369
?>