Passed
Pull Request — release-2.1 (#5502)
by Michael
04:33
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
	/** @var int $cachedtime time object cached */
51
	protected $cachedtime;
52
53
	/** @var string $cachedtype type of object cached */
54
	protected $cachedtype;
55
56
	/** @var int $cachedsize size of object cached */
57
	protected $cachedsize;
58
59
	/** @var string $cachedbody body of object cached */
60
	protected $cachedbody;
61
62
	/**
63
	 * Constructor, loads up the Settings for the proxy
64
	 *
65
	 * @access public
66
	 */
67
	public function __construct()
68
	{
69
		global $image_proxy_enabled, $image_proxy_maxsize, $image_proxy_secret, $cachedir, $sourcedir;
70
71
		require_once(dirname(__FILE__) . '/Settings.php');
72
		require_once($sourcedir . '/Subs.php');
73
74
		// Turn off all error reporting; any extra junk makes for an invalid image.
75
		error_reporting(0);
76
77
		$this->enabled = (bool) $image_proxy_enabled;
78
		$this->maxSize = (int) $image_proxy_maxsize;
79
		$this->secret = (string) $image_proxy_secret;
80
		$this->cache = $cachedir . '/images';
81
		$this->maxDays = 5;
82
		$this->cachedtime = null;
83
		$this->cachedtype = null;
84
		$this->cachedsize = null;
85
		$this->cachedbody = null;
86
	}
87
88
	/**
89
	 * Checks whether the request is valid or not
90
	 *
91
	 * @access public
92
	 * @return bool Whether the request is valid
93
	 */
94
	public function checkRequest()
95
	{
96
		if (!$this->enabled)
97
			return false;
98
99
		// Try to create the image cache directory if it doesn't exist
100
		if (!file_exists($this->cache))
101
			if (!mkdir($this->cache) || !copy(dirname($this->cache) . '/index.php', $this->cache . '/index.php'))
102
				return false;
103
104
		// Basic sanity check
105
		$_GET['request'] = validate_iri($_GET['request']);
106
107
		// We aren't going anywhere without these
108
		if (empty($_GET['hash']) || empty($_GET['request']))
109
			return false;
110
111
		$hash = $_GET['hash'];
112
		$request = $_GET['request'];
113
114
		if (md5($request . $this->secret) != $hash)
115
			return false;
116
117
		// Ensure any non-ASCII characters in the URL are encoded correctly
118
		$request = iri_to_url($request);
119
120
		// Attempt to cache the request if it doesn't exist
121
		if (!$this->isCached($request))
122
			return $this->cacheImage($request);
123
124
		return true;
125
	}
126
127
	/**
128
	 * Serves the request
129
	 *
130
	 * @access public
131
	 * @return void
132
	 */
133
	public function serve()
134
	{
135
		$request = $_GET['request'];
136
		// Did we get an error when trying to fetch the image
137
		$response = $this->checkRequest();
138
		if ($response === -1)
0 ignored issues
show
The condition $response === -1 is always false.
Loading history...
139
		{
140
			// Throw a 404
141
			send_http_status(404);
142
			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...
143
		}
144
		// Right, image not cached? Simply redirect, then.
145
		if ($response === 0)
0 ignored issues
show
The condition $response === 0 is always false.
Loading history...
146
		{
147
			$this::redirectexit($request);
148
		}
149
150
		// We should have a cached image at this point
151
		$cached_file = $this->getCachedPath($request);
152
153
		// Read from cache if you need to...
154
		if ($this->cachedbody === null)
155
		{
156
			$cached = json_decode(file_get_contents($cached_file), true);
157
			$this->cachedtime = $cached['time'];
158
			$this->cachedtype = $cached['content_type'];
159
			$this->cachedsize = $cached['size'];
160
			$this->cachedbody = $cached['body'];
161
		}
162
163
		$time = time();
164
165
		// Is the cache expired?
166
		if ($time - $this->cachedtime > ($this->maxDays * 86400))
167
		{
168
			@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

168
			/** @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...
169
			if ($this->checkRequest())
170
				$this->serve();
171
			$this::redirectexit($request);
172
		}
173
174
		$eTag = '"' . substr(sha1($request) . $this->cachedtime, 0, 64) . '"';
175
		if (!empty($_SERVER['HTTP_IF_NONE_MATCH']) && strpos($_SERVER['HTTP_IF_NONE_MATCH'], $eTag) !== false)
176
		{
177
			send_http_status(304);
178
			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...
179
		}
180
181
		// Make sure we're serving an image
182
		$contentParts = explode('/', !empty($this->cachedtype) ? $this->cachedtype : '');
183
		if ($contentParts[0] != 'image')
184
			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...
185
186
		$max_age = $time - $this->cachedtime + (5 * 86400);
187
		header('content-type: ' . $this->cachedtype);
188
		header('content-length: ' . $this->cachedsize);
189
		header('cache-control: public, max-age=' . $max_age);
190
		header('last-modified: ' . gmdate('D, d M Y H:i:s', $this->cachedtime) . ' GMT');
191
		header('etag: ' . $eTag);
192
		echo base64_decode($this->cachedbody);
193
	}
194
195
	/**
196
	 * Returns the request's hashed filepath
197
	 *
198
	 * @access public
199
	 * @param string $request The request to get the path for
200
	 * @return string The hashed filepath for the specified request
201
	 */
202
	protected function getCachedPath($request)
203
	{
204
		return $this->cache . '/' . sha1($request . $this->secret);
205
	}
206
207
	/**
208
	 * Check whether the image exists in local cache or not
209
	 *
210
	 * @access protected
211
	 * @param string $request The image to check for in the cache
212
	 * @return bool Whether or not the requested image is cached
213
	 */
214
	protected function isCached($request)
215
	{
216
		return file_exists($this->getCachedPath($request));
217
	}
218
219
	/**
220
	 * Attempts to cache the image while validating it
221
	 *
222
	 * @access protected
223
	 * @param string $request The image to cache/validate
224
	 * @return int -1 error, 0 too big, 1 valid image
225
	 */
226
	protected function cacheImage($request)
227
	{
228
		$dest = $this->getCachedPath($request);
229
		$ext = strtolower(pathinfo(parse_url($request, PHP_URL_PATH), PATHINFO_EXTENSION));
230
231
		$image = fetch_web_data($request);
232
233
		// Looks like nobody was home
234
		if (empty($image))
235
			return 0;
236
237
		// What kind of file did they give us?
238
		$finfo = finfo_open(FILEINFO_MIME_TYPE);
239
		$mime_type = finfo_buffer($finfo, $image);
240
241
		// SVG needs a little extra care
242
		if ($ext == 'svg' && $mime_type == 'text/plain')
243
			$mime_type = 'image/svg+xml';
244
245
		// Make sure the url is returning an image
246
		if (strpos($mime_type, 'image/') !== 0)
247
			return 0;
248
249
		// Validate the filesize
250
		$size = strlen($image);
251
		if ($size > ($this->maxSize * 1024))
252
			return 0;
253
254
		// Populate object for current serve execution (so you don't have to read it again...)
255
		$this->cachedtime = time();
256
		$this->cachedtype = $mime_type;
257
		$this->cachedsize = $size;
258
		$this->cachedbody = base64_encode($image);
259
		
260
		// Cache it for later
261
		return file_put_contents($dest, json_encode(array(
262
			'content_type' => $this->cachedtype,
263
			'size' => $this->cachedsize,
264
			'time' => $this->cachedtime,
265
			'body' => $this->cachedbody,
266
		))) === false ? -1 : 1;
267
	}
268
269
	/**
270
	 * Static helper function to redirect a request
271
	 *
272
	 * @access public
273
	 * @param type $request
274
	 * @return void
275
	 */
276
	static public function redirectexit($request)
277
	{
278
		header('Location: ' . un_htmlspecialchars($request), false, 301);
279
		exit;
280
	}
281
282
	/**
283
	 * Delete all old entries
284
	 *
285
	 * @access public
286
	 * @return void
287
	 */
288
	public function housekeeping()
289
	{
290
		$path = $this->cache . '/';
291
		if ($handle = opendir($path))
292
		{
293
			while (false !== ($file = readdir($handle)))
294
			{
295
				$filelastmodified = filemtime($path . $file);
296
297
				if ((time() - $filelastmodified) > ($this->maxDays * 86400))
298
				{
299
					unlink($path . $file);
300
				}
301
			}
302
303
			closedir($handle);
304
		}
305
	}
306
}
307
308
if (empty($proxyhousekeeping))
309
{
310
	$proxy = new ProxyServer();
311
	$proxy->serve();
312
}
313
314
?>