Passed
Pull Request — release-2.1 (#5753)
by Mathias
04:18
created

proxy.php (4 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 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', '2019');
27
28
/**
29
 * Class ProxyServer
30
 */
31
class ProxyServer
32
{
33
	/** @var bool $enabled Whether or not this is enabled */
34
	protected $enabled;
35
36
	/** @var int $maxSize The maximum size for files to cache */
37
	protected $maxSize;
38
39
	/** @var string $secret A secret code used for hashing */
40
	protected $secret;
41
42
	/** @var string The cache directory */
43
	protected $cache;
44
45
	/** @var int $maxDays until entries get deleted */
46
	protected $maxDays;
47
48
	/** @var int $cachedtime time object cached */
49
	protected $cachedtime;
50
51
	/** @var string $cachedtype type of object cached */
52
	protected $cachedtype;
53
54
	/** @var int $cachedsize size of object cached */
55
	protected $cachedsize;
56
57
	/** @var string $cachedbody body of object cached */
58
	protected $cachedbody;
59
60
	/**
61
	 * Constructor, loads up the Settings for the proxy
62
	 *
63
	 * @access public
64
	 */
65
	public function __construct()
66
	{
67
		global $image_proxy_enabled, $image_proxy_maxsize, $image_proxy_secret, $cachedir, $sourcedir;
68
69
		require_once(dirname(__FILE__) . '/Settings.php');
70
		require_once($sourcedir . '/Subs.php');
71
72
		// Turn off all error reporting; any extra junk makes for an invalid image.
73
		error_reporting(0);
74
75
		$this->enabled = (bool) $image_proxy_enabled;
76
		$this->maxSize = (int) $image_proxy_maxsize;
77
		$this->secret = (string) $image_proxy_secret;
78
		$this->cache = $cachedir . '/images';
79
		$this->maxDays = 5;
80
	}
81
82
	/**
83
	 * Checks whether the request is valid or not
84
	 *
85
	 * @access public
86
	 * @return bool Whether the request is valid
87
	 */
88
	public function checkRequest()
89
	{
90
		if (!$this->enabled)
91
			return false;
92
93
		// Try to create the image cache directory if it doesn't exist
94
		if (!file_exists($this->cache))
95
			if (!mkdir($this->cache) || !copy(dirname($this->cache) . '/index.php', $this->cache . '/index.php'))
96
				return false;
97
98
		// Basic sanity check
99
		$_GET['request'] = validate_iri($_GET['request']);
100
101
		// We aren't going anywhere without these
102
		if (empty($_GET['hash']) || empty($_GET['request']))
103
			return false;
104
105
		$hash = $_GET['hash'];
106
		$request = $_GET['request'];
107
108
		if (hash_hmac('sha1', $request, $this->secret) != $hash)
109
			return false;
110
111
		// Ensure any non-ASCII characters in the URL are encoded correctly
112
		$request = iri_to_url($request);
113
114
		// Attempt to cache the request if it doesn't exist
115
		if (!$this->isCached($request))
116
			return $this->cacheImage($request);
117
118
		return false;
119
	}
120
121
	/**
122
	 * Serves the request
123
	 *
124
	 * @access public
125
	 */
126
	public function serve()
127
	{
128
		$request = $_GET['request'];
129
		// Did we get an error when trying to fetch the image
130
		$response = $this->checkRequest();
131
		if (!$response)
132
		{
133
			// Throw a 404
134
			send_http_status(404);
135
			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...
136
		}
137
138
		// We should have a cached image at this point
139
		$cached_file = $this->getCachedPath($request);
140
141
		// Read from cache if you need to...
142
		if ($this->cachedbody === null)
143
		{
144
			$cached = json_decode(file_get_contents($cached_file), true);
145
			$this->cachedtime = $cached['time'];
146
			$this->cachedtype = $cached['content_type'];
147
			$this->cachedsize = $cached['size'];
148
			$this->cachedbody = $cached['body'];
149
		}
150
151
		$time = time();
152
153
		// Is the cache expired? Delete and reload.
154
		if ($time - $this->cachedtime > ($this->maxDays * 86400))
155
		{
156
			@unlink($cached_file);
157
			if ($this->checkRequest())
158
				$this->serve();
159
			$this->redirectexit($request);
160
		}
161
162
		$eTag = '"' . substr(sha1($request) . $this->cachedtime, 0, 64) . '"';
163
		if (!empty($_SERVER['HTTP_IF_NONE_MATCH']) && strpos($_SERVER['HTTP_IF_NONE_MATCH'], $eTag) !== false)
164
		{
165
			send_http_status(304);
166
			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...
167
		}
168
169
		// Make sure we're serving an image
170
		$contentParts = explode('/', !empty($this->cachedtype) ? $this->cachedtype : '');
171
		if ($contentParts[0] != 'image')
172
			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...
173
174
		$max_age = $time - $this->cachedtime + (5 * 86400);
175
		header('content-type: ' . $this->cachedtype);
176
		header('content-length: ' . $this->cachedsize);
177
		header('cache-control: public, max-age=' . $max_age);
178
		header('last-modified: ' . gmdate('D, d M Y H:i:s', $this->cachedtime) . ' GMT');
179
		header('etag: ' . $eTag);
180
		echo base64_decode($this->cachedbody);
181
	}
182
183
	/**
184
	 * Returns the request's hashed filepath
185
	 *
186
	 * @access public
187
	 * @param string $request The request to get the path for
188
	 * @return string The hashed filepath for the specified request
189
	 */
190
	protected function getCachedPath($request)
191
	{
192
		return $this->cache . '/' . sha1($request . $this->secret);
193
	}
194
195
	/**
196
	 * Check whether the image exists in local cache or not
197
	 *
198
	 * @access protected
199
	 * @param string $request The image to check for in the cache
200
	 * @return bool Whether or not the requested image is cached
201
	 */
202
	protected function isCached($request)
203
	{
204
		return file_exists($this->getCachedPath($request));
205
	}
206
207
	/**
208
	 * Attempts to cache the image while validating it
209
	 *
210
	 * Redirects to the origin if
211
	 *    - the image couldn't be fetched
212
	 *    - the MIME type doesn't indicate an image
213
	 *    - the image is too large
214
	 *
215
	 * @access protected
216
	 * @param string $request The image to cache/validate
217
	 * @return bool Whether the specified image was cached
218
	 */
219
	protected function cacheImage($request)
220
	{
221
		$dest = $this->getCachedPath($request);
222
		$ext = strtolower(pathinfo(parse_url($request, PHP_URL_PATH), PATHINFO_EXTENSION));
223
224
		$image = fetch_web_data($request);
225
226
		// Looks like nobody was home
227
		if (empty($image))
228
			$this->redirectexit($request);
229
230
		// What kind of file did they give us?
231
		$finfo = finfo_open(FILEINFO_MIME_TYPE);
232
		$mime_type = finfo_buffer($finfo, $image);
233
234
		// SVG needs a little extra care
235
		if ($ext == 'svg' && $mime_type == 'text/plain')
236
			$mime_type = 'image/svg+xml';
237
238
		// Make sure the url is returning an image
239
		if (strpos($mime_type, 'image/') !== 0)
240
			$this->redirectexit($request);
241
242
		// Validate the filesize
243
		$size = strlen($image);
244
		if ($size > ($this->maxSize * 1024))
245
			$this->redirectexit($request);
246
247
		// Populate object for current serve execution (so you don't have to read it again...)
248
		$this->cachedtime = time();
249
		$this->cachedtype = $mime_type;
250
		$this->cachedsize = $size;
251
		$this->cachedbody = base64_encode($image);
252
253
		// Cache it for later
254
		return file_put_contents($dest, json_encode(array(
255
			'content_type' => $this->cachedtype,
256
			'size' => $this->cachedsize,
257
			'time' => $this->cachedtime,
258
			'body' => $this->cachedbody,
259
		))) !== false;
260
	}
261
262
	/**
263
	 * A helper function to redirect a request
264
	 *
265
	 * @access private
266
	 * @param string $request
267
	 */
268
	private function redirectexit($request)
269
	{
270
		header('Location: ' . un_htmlspecialchars($request), false, 301);
271
		exit;
272
	}
273
274
	/**
275
	 * Delete all old entries
276
	 *
277
	 * @access public
278
	 */
279
	public function housekeeping()
280
	{
281
		$path = $this->cache . '/';
282
		if ($handle = opendir($path))
283
		{
284
			while (false !== ($file = readdir($handle)))
285
			{
286
				$filelastmodified = filemtime($path . $file);
287
288
				if ((time() - $filelastmodified) > ($this->maxDays * 86400))
289
				{
290
					unlink($path . $file);
291
				}
292
			}
293
294
			closedir($handle);
295
		}
296
	}
297
}
298
299
if (SMF == 'PROXY')
0 ignored issues
show
The condition SMF == 'PROXY' is always false.
Loading history...
300
{
301
	$proxy = new ProxyServer();
302
	$proxy->serve();
303
}
304
305
?>