Passed
Pull Request — release-2.1 (#5917)
by Mathias
04:33
created

proxy.php (1 issue)

Labels
Severity
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 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
	define('SMF', 'PROXY');
18
19
if (!defined('SMF_VERSION'))
20
	define('SMF_VERSION', '2.1 RC2');
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', '2020');
27
28
if (!defined('JQUERY_VERSION'))
29
	define('JQUERY_VERSION', '3.4.1');
30
31
if (!defined('POSTGRE_TITLE'))
32
	define('POSTGRE_TITLE', 'PostgreSQL');
33
34
if (!defined('MYSQL_TITLE'))
35
	define('MYSQL_TITLE', 'MySQL');
36
37
/if (!defined('SMF_USER_AGENT'))
0 ignored issues
show
A parse error occurred: Syntax error, unexpected '/' on line 37 at column 0
Loading history...
38
	define('SMF_USER_AGENT', 'Mozilla/5.0 (' . php_uname('s') . ' ' . php_uname('m') . ') AppleWebKit/605.1.15 (KHTML, like Gecko)  SMF/' . strtr(SMF_VERSION, ' ', '.'));
39
40
**
41
 * Class ProxyServer
42
 */
43
class ProxyServer
44
{
45
	/** @var bool $enabled Whether or not this is enabled */
46
	protected $enabled;
47
48
	/** @var int $maxSize The maximum size for files to cache */
49
	protected $maxSize;
50
51
	/** @var string $secret A secret code used for hashing */
52
	protected $secret;
53
54
	/** @var string The cache directory */
55
	protected $cache;
56
57
	/** @var int $maxDays until entries get deleted */
58
	protected $maxDays;
59
60
	/** @var int $cachedtime time object cached */
61
	protected $cachedtime;
62
63
	/** @var string $cachedtype type of object cached */
64
	protected $cachedtype;
65
66
	/** @var int $cachedsize size of object cached */
67
	protected $cachedsize;
68
69
	/** @var string $cachedbody body of object cached */
70
	protected $cachedbody;
71
72
	/**
73
	 * Constructor, loads up the Settings for the proxy
74
	 *
75
	 * @access public
76
	 */
77
	public function __construct()
78
	{
79
		global $image_proxy_enabled, $image_proxy_maxsize, $image_proxy_secret, $cachedir, $sourcedir;
80
81
		require_once(dirname(__FILE__) . '/Settings.php');
82
		require_once($sourcedir . '/Subs.php');
83
84
		// Turn off all error reporting; any extra junk makes for an invalid image.
85
		error_reporting(0);
86
87
		$this->enabled = (bool) $image_proxy_enabled;
88
		$this->maxSize = (int) $image_proxy_maxsize;
89
		$this->secret = (string) $image_proxy_secret;
90
		$this->cache = $cachedir . '/images';
91
		$this->maxDays = 5;
92
	}
93
94
	/**
95
	 * Checks whether the request is valid or not
96
	 *
97
	 * @access public
98
	 * @return bool Whether the request is valid
99
	 */
100
	public function checkRequest()
101
	{
102
		if (!$this->enabled)
103
			return false;
104
105
		// Try to create the image cache directory if it doesn't exist
106
		if (!file_exists($this->cache))
107
			if (!mkdir($this->cache) || !copy(dirname($this->cache) . '/index.php', $this->cache . '/index.php'))
108
				return false;
109
110
		// Basic sanity check
111
		$_GET['request'] = validate_iri($_GET['request']);
112
113
		// We aren't going anywhere without these
114
		if (empty($_GET['hash']) || empty($_GET['request']))
115
			return false;
116
117
		$hash = $_GET['hash'];
118
		$request = $_GET['request'];
119
120
		if (hash_hmac('sha1', $request, $this->secret) != $hash)
121
			return false;
122
123
		// Ensure any non-ASCII characters in the URL are encoded correctly
124
		$request = iri_to_url($request);
125
126
		// Attempt to cache the request if it doesn't exist
127
		if (!$this->isCached($request))
128
			return $this->cacheImage($request);
129
130
		return false;
131
	}
132
133
	/**
134
	 * Serves the request
135
	 *
136
	 * @access public
137
	 */
138
	public function serve()
139
	{
140
		$request = $_GET['request'];
141
		// Did we get an error when trying to fetch the image
142
		$response = $this->checkRequest();
143
		if (!$response)
144
		{
145
			// Throw a 404
146
			send_http_status(404);
147
			exit;
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? Delete and reload.
166
		if ($time - $this->cachedtime > ($this->maxDays * 86400))
167
		{
168
			@unlink($cached_file);
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;
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;
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
	 * Redirects to the origin if
223
	 *    - the image couldn't be fetched
224
	 *    - the MIME type doesn't indicate an image
225
	 *    - the image is too large
226
	 *
227
	 * @access protected
228
	 * @param string $request The image to cache/validate
229
	 * @return bool Whether the specified image was cached
230
	 */
231
	protected function cacheImage($request)
232
	{
233
		$dest = $this->getCachedPath($request);
234
		$ext = strtolower(pathinfo(parse_url($request, PHP_URL_PATH), PATHINFO_EXTENSION));
235
236
		$image = fetch_web_data($request);
237
238
		// Looks like nobody was home
239
		if (empty($image))
240
			$this->redirectexit($request);
241
242
		// What kind of file did they give us?
243
		$finfo = finfo_open(FILEINFO_MIME_TYPE);
244
		$mime_type = finfo_buffer($finfo, $image);
245
246
		// SVG needs a little extra care
247
		if ($ext == 'svg' && $mime_type == 'text/plain')
248
			$mime_type = 'image/svg+xml';
249
250
		// Make sure the url is returning an image
251
		if (strpos($mime_type, 'image/') !== 0)
252
			$this->redirectexit($request);
253
254
		// Validate the filesize
255
		$size = strlen($image);
256
		if ($size > ($this->maxSize * 1024))
257
			$this->redirectexit($request);
258
259
		// Populate object for current serve execution (so you don't have to read it again...)
260
		$this->cachedtime = time();
261
		$this->cachedtype = $mime_type;
262
		$this->cachedsize = $size;
263
		$this->cachedbody = base64_encode($image);
264
265
		// Cache it for later
266
		return file_put_contents($dest, json_encode(array(
267
			'content_type' => $this->cachedtype,
268
			'size' => $this->cachedsize,
269
			'time' => $this->cachedtime,
270
			'body' => $this->cachedbody,
271
		))) !== false;
272
	}
273
274
	/**
275
	 * A helper function to redirect a request
276
	 *
277
	 * @access private
278
	 * @param string $request
279
	 */
280
	private function redirectexit($request)
281
	{
282
		header('Location: ' . un_htmlspecialchars($request), false, 301);
283
		exit;
284
	}
285
286
	/**
287
	 * Delete all old entries
288
	 *
289
	 * @access public
290
	 */
291
	public function housekeeping()
292
	{
293
		$path = $this->cache . '/';
294
		if ($handle = opendir($path))
295
		{
296
			while (false !== ($file = readdir($handle)))
297
			{
298
				$filelastmodified = filemtime($path . $file);
299
300
				if ((time() - $filelastmodified) > ($this->maxDays * 86400))
301
				{
302
					unlink($path . $file);
303
				}
304
			}
305
306
			closedir($handle);
307
		}
308
	}
309
}
310
311
if (SMF == 'PROXY')
312
{
313
	$proxy = new ProxyServer();
314
	$proxy->serve();
315
}
316
317
?>