Passed
Pull Request — release-2.1 (#5337)
by Michael
04:28
created

proxy.php (6 issues)

1
<?php
2
3
/**
4
 * This is a lightweight proxy for serving images, generally meant to be used alongside SSL
5
 *
6
 * Simple Machines Forum (SMF)
7
 *
8
 * @package SMF
9
 * @author Simple Machines http://www.simplemachines.org
10
 * @copyright 2019 Simple Machines and individual contributors
11
 * @license http://www.simplemachines.org/about/smf/license.php BSD
12
 *
13
 * @version 2.1 RC1
14
 */
15
16
if (!defined('SMF'))
17
	define('SMF', 'proxy');
18
19
if (!defined('SMF_VERSION'))
20
	define('SMF_VERSION', '2.1 RC1');
21
22
if (!defined('SMF_FULL_VERSION'))
23
	define('SMF_FULL_VERSION', 'SMF ' . SMF_VERSION);
24
25
if (!defined('SMF_SOFTWARE_YEAR'))
26
	define('SMF_SOFTWARE_YEAR', '2019');
27
28
global $proxyhousekeeping;
29
30
/**
31
 * Class ProxyServer
32
 */
33
class ProxyServer
34
{
35
	/** @var bool $enabled Whether or not this is enabled */
36
	protected $enabled;
37
38
	/** @var int $maxSize The maximum size for files to cache */
39
	protected $maxSize;
40
41
	/** @var string $secret A secret code used for hashing */
42
	protected $secret;
43
44
	/** @var string The cache directory */
45
	protected $cache;
46
47
	/** @var int $maxDays until entries get deleted */
48
	protected $maxDays;
49
50
	/**
51
	 * Constructor, loads up the Settings for the proxy
52
	 *
53
	 * @access public
54
	 */
55
	public function __construct()
56
	{
57
		global $image_proxy_enabled, $image_proxy_maxsize, $image_proxy_secret, $cachedir, $sourcedir;
58
59
		require_once(dirname(__FILE__) . '/Settings.php');
60
		require_once($sourcedir . '/Subs.php');
61
62
		// Turn off all error reporting; any extra junk makes for an invalid image.
63
		error_reporting(0);
64
65
		$this->enabled = (bool) $image_proxy_enabled;
66
		$this->maxSize = (int) $image_proxy_maxsize;
67
		$this->secret = (string) $image_proxy_secret;
68
		$this->cache = $cachedir . '/images';
69
		$this->maxDays = 5;
70
	}
71
72
	/**
73
	 * Checks whether the request is valid or not
74
	 *
75
	 * @access public
76
	 * @return bool Whether the request is valid
77
	 */
78
	public function checkRequest()
79
	{
80
		if (!$this->enabled)
81
			return false;
82
83
		// Try to create the image cache directory if it doesn't exist
84
		if (!file_exists($this->cache))
85
			if (!mkdir($this->cache) || !copy(dirname($this->cache) . '/index.php', $this->cache . '/index.php'))
86
				return false;
87
88
		// Basic sanity check
89
		$_GET['request'] = validate_iri($_GET['request']);
90
91
		// We aren't going anywhere without these
92
		if (empty($_GET['hash']) || empty($_GET['request']))
93
			return false;
94
95
		$hash = $_GET['hash'];
96
		$request = $_GET['request'];
97
98
		if (md5($request . $this->secret) != $hash)
99
			return false;
100
101
		// Ensure any non-ASCII characters in the URL are encoded correctly
102
		$request = iri_to_url($request);
103
104
		// Attempt to cache the request if it doesn't exist
105
		if (!$this->isCached($request))
106
			return $this->cacheImage($request);
107
108
		return true;
109
	}
110
111
	/**
112
	 * Serves the request
113
	 *
114
	 * @access public
115
	 * @return void
116
	 */
117
	public function serve()
118
	{
119
		$request = $_GET['request'];
120
		$cached_file = $this->getCachedPath($request);
121
		$cached = json_decode(file_get_contents($cached_file), true);
122
123
		// Did we get an error when trying to fetch the image
124
		$response = $this->checkRequest();
125
		if ($response === -1)
0 ignored issues
show
The condition $response === -1 is always false.
Loading history...
126
		{
127
			// Throw a 404
128
			send_http_status(404);
129
			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...
130
		}
131
		// Right, image not cached? Simply redirect, then.
132
		if ($response === 0)
0 ignored issues
show
The condition $response === 0 is always false.
Loading history...
133
		{
134
			$this::redirectexit($request);
135
		}
136
137
		$time = time();
138
139
		// Is the cache expired?
140
		if (!$cached || $time - $cached['time'] > ($this->maxDays * 86400))
141
		{
142
			@unlink($cached_file);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

142
			/** @scrutinizer ignore-unhandled */ @unlink($cached_file);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
143
			if ($this->checkRequest())
144
				$this->serve();
145
			$this::redirectexit($request);
146
		}
147
148
		$eTag = '"' . substr(sha1($request) . $cached['time'], 0, 64) . '"';
149
		if (!empty($_SERVER['HTTP_IF_NONE_MATCH']) && strpos($_SERVER['HTTP_IF_NONE_MATCH'], $eTag) !== false)
150
		{
151
			send_http_status(304);
152
			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...
153
		}
154
155
		// Make sure we're serving an image
156
		$contentParts = explode('/', !empty($cached['content_type']) ? $cached['content_type'] : '');
157
		if ($contentParts[0] != 'image')
158
			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...
159
160
		$max_age = $time - $cached['time'] + (5 * 86400);
161
		header('content-type: ' . $cached['content_type']);
162
		header('content-length: ' . $cached['size']);
163
		header('cache-control: public, max-age=' . $max_age);
164
		header('last-modified: ' . gmdate('D, d M Y H:i:s', $cached['time']) . ' GMT');
165
		header('etag: ' . $eTag);
166
		echo base64_decode($cached['body']);
167
	}
168
169
	/**
170
	 * Returns the request's hashed filepath
171
	 *
172
	 * @access public
173
	 * @param string $request The request to get the path for
174
	 * @return string The hashed filepath for the specified request
175
	 */
176
	protected function getCachedPath($request)
177
	{
178
		return $this->cache . '/' . sha1($request . $this->secret);
179
	}
180
181
	/**
182
	 * Check whether the image exists in local cache or not
183
	 *
184
	 * @access protected
185
	 * @param string $request The image to check for in the cache
186
	 * @return bool Whether or not the requested image is cached
187
	 */
188
	protected function isCached($request)
189
	{
190
		return file_exists($this->getCachedPath($request));
191
	}
192
193
	/**
194
	 * Attempts to cache the image while validating it
195
	 *
196
	 * @access protected
197
	 * @param string $request The image to cache/validate
198
	 * @return int -1 error, 0 too big, 1 valid image
199
	 */
200
	protected function cacheImage($request)
201
	{
202
		$dest = $this->getCachedPath($request);
203
		$ext = strtolower(pathinfo(parse_url($request, PHP_URL_PATH), PATHINFO_EXTENSION));
204
205
		$image = fetch_web_data($request);
206
207
		// Looks like nobody was home
208
		if (empty($image))
209
			return -1;
210
211
		// What kind of file did they give us?
212
		$finfo = finfo_open(FILEINFO_MIME_TYPE);
213
		$mime_type = finfo_buffer($finfo, $image);
214
215
		// SVG needs a little extra care
216
		if ($ext == 'svg' && $mime_type == 'text/plain')
217
			$mime_type = 'image/svg+xml';
218
219
		// Make sure the url is returning an image
220
		if (strpos($mime_type, 'image/') !== 0)
221
			return -1;
222
223
		// Validate the filesize
224
		$size = strlen($image);
225
		if ($size > ($this->maxSize * 1024))
226
			return 0;
227
228
		// Cache it for later
229
		return file_put_contents($dest, json_encode(array(
230
			'content_type' => $mime_type,
231
			'size' => $size,
232
			'time' => time(),
233
			'body' => base64_encode($image),
234
		))) === false ? -1 : 1;
235
	}
236
237
	/**
238
	 * Static helper function to redirect a request
239
	 *
240
	 * @access public
241
	 * @param type $request
242
	 * @return void
243
	 */
244
	static public function redirectexit($request)
245
	{
246
		header('Location: ' . $request, false, 301);
247
		exit;
248
	}
249
250
	/**
251
	 * Delete all old entries
252
	 *
253
	 * @access public
254
	 * @return void
255
	 */
256
	public function housekeeping()
257
	{
258
		$path = $this->cache . '/';
259
		if ($handle = opendir($path))
260
		{
261
			while (false !== ($file = readdir($handle)))
262
			{
263
				$filelastmodified = filemtime($path . $file);
264
265
				if ((time() - $filelastmodified) > ($this->maxDays * 86400))
266
				{
267
					unlink($path . $file);
268
				}
269
			}
270
271
			closedir($handle);
272
		}
273
	}
274
}
275
276
if (empty($proxyhousekeeping))
277
{
278
	$proxy = new ProxyServer();
279
	$proxy->serve();
280
}
281
282
?>