Passed
Push — master ( c0a3a7...3b84a4 )
by Jeroen
58:51
created

CacheHandler::send403()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 2
rs 10
c 0
b 0
f 0
ccs 0
cts 2
cp 0
crap 2
1
<?php
2
namespace Elgg\Application;
3
4
use Elgg\Application;
5
use Elgg\Config;
6
use Elgg\Http\Request;
7
use Symfony\Component\HttpFoundation\BinaryFileResponse;
8
use Symfony\Component\HttpFoundation\Response;
9
use Symfony\Component\HttpFoundation\StreamedResponse;
10
11
/**
12
 * Simplecache handler
13
 *
14
 * @access private
15
 */
16
class CacheHandler {
17
	
18
	public static $extensions = [
19
		'bmp' => "image/bmp",
20
		'css' => "text/css",
21
		'gif' => "image/gif",
22
		'html' => "text/html",
23
		'ico' => "image/x-icon",
24
		'jpeg' => "image/jpeg",
25
		'jpg' => "image/jpeg",
26
		'js' => "application/javascript",
27
		'json' => "application/json",
28
		'png' => "image/png",
29
		'svg' => "image/svg+xml",
30
		'swf' => "application/x-shockwave-flash",
31
		'tiff' => "image/tiff",
32
		'webp' => "image/webp",
33
		'xml' => "text/xml",
34
		'eot' => "application/vnd.ms-fontobject",
35
		'ttf' => "application/font-ttf",
36
		'woff' => "application/font-woff",
37
		'woff2' => "application/font-woff2",
38
		'otf' => "application/font-otf",
39
	];
40
41
	public static $utf8_content_types = [
42
		"text/css",
43
		"text/html",
44
		"application/javascript",
45
		"application/json",
46
		"image/svg+xml",
47
		"text/xml",
48
	];
49
50
	/** @var Config */
51
	private $config;
52
53
	/** @var Request */
54
	private $request;
55
	
56
	/** @var bool */
57
	protected $simplecache_enabled;
58
59
	/**
60
	 * Constructor
61
	 *
62
	 * @param Config  $config              Elgg configuration
63
	 * @param Request $request             HTTP request
64
	 * @param bool    $simplecache_enabled Is the simplecache enabled?
65
	 */
66 9
	public function __construct(Config $config, Request $request, $simplecache_enabled) {
67 9
		$this->config = $config;
68 9
		$this->request = $request;
69 9
		$this->simplecache_enabled = $simplecache_enabled;
70 9
	}
71
72
	/**
73
	 * Handle a request for a cached view
74
	 *
75
	 * @param Request     $request Elgg request
76
	 * @param Application $app     Elgg application
77
	 * @return Response (unprepared)
78
	 */
79
	public function handleRequest(Request $request, Application $app) {
80
		$config = $this->config;
81
82
		$parsed = $this->parsePath($request->getElggPath());
83
		if (!$parsed) {
0 ignored issues
show
Bug Best Practice introduced by Steve Clay
The expression $parsed of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
84
			return $this->send403();
85
		}
86
		
87
		$ts = $parsed['ts'];
88
		$view = $parsed['view'];
89
		$viewtype = $parsed['viewtype'];
90
91
		$content_type = $this->getContentType($view);
92
		if (empty($content_type)) {
93
			return $this->send403("Asset must have a valid file extension");
94
		}
95
96
		$response = Response::create();
97
		if (in_array($content_type, self::$utf8_content_types)) {
98
			$response->headers->set('Content-Type', "$content_type;charset=utf-8", true);
99
		} else {
100
			$response->headers->set('Content-Type', $content_type, true);
101
		}
102
103
		if (!$this->simplecache_enabled) {
104
			$app->bootCore();
105
			header_remove('Cache-Control');
106
			header_remove('Pragma');
107
			header_remove('Expires');
108
			
109
			if (!$this->isCacheableView($view)) {
110
				return $this->send403("Requested view ({$view}) is not an asset");
111
			}
112
113
			$content = $this->getProcessedView($view, $viewtype);
114
			if ($content === false) {
115
				return $this->send403();
116
			}
117
118
			$etag = '"' . md5($content) . '"';
119
			$this->setRevalidateHeaders($etag, $response);
120
			if ($this->is304($etag)) {
121
				return Response::create()->setNotModified();
122
			}
123
124
			return $response->setContent($content);
125
		}
126
127
		$etag = "\"$ts\"";
128
		if ($this->is304($etag)) {
129
			return Response::create()->setNotModified();
130
		}
131
132
		// trust the client but check for an existing cache file
133
		$filename = $config->cacheroot . "views_simplecache/$ts/$viewtype/$view";
134
		if (file_exists($filename)) {
135
			$this->sendCacheHeaders($etag, $response);
136
			return BinaryFileResponse::create($filename, 200, $response->headers->all());
137
		}
138
139
		// the hard way
140
		$app->bootCore();
141
		header_remove('Cache-Control');
142
		header_remove('Pragma');
143
		header_remove('Expires');
144
145
		elgg_set_viewtype($viewtype);
146
		if (!$this->isCacheableView($view)) {
147
			return $this->send403("Requested view is not an asset");
148
		}
149
150
		$lastcache = (int) $config->lastcache;
151
152
		$filename = $config->cacheroot . "views_simplecache/$lastcache/$viewtype/$view";
153
154
		if ($lastcache == $ts) {
155
			$this->sendCacheHeaders($etag, $response);
156
157
			$content = $this->getProcessedView($view, $viewtype);
158
159
			$dir_name = dirname($filename);
160
			if (!is_dir($dir_name)) {
161
				// PHP and the server accessing the cache symlink may be a different user. And here
162
				// it's safe to make everything readable anyway.
163
				mkdir($dir_name, 0775, true);
164
			}
165
166
			file_put_contents($filename, $content);
167
			chmod($filename, 0664);
168
		} else {
169
			// if wrong timestamp, don't send HTTP cache
170
			$content = $this->getProcessedView($view, $viewtype);
171
		}
172
173
		return $response->setContent($content);
174
	}
175
176
	/**
177
	 * Parse a request
178
	 *
179
	 * @param string $path Request URL path
180
	 * @return array Cache parameters (empty array if failure)
181
	 */
182 6
	public function parsePath($path) {
183
		// no '..'
184 6
		if (false !== strpos($path, '..')) {
185 1
			return [];
186
		}
187
		// only alphanumeric characters plus /, ., -, and _
188 5
		if (preg_match('#[^a-zA-Z0-9/\.\-_]#', $path)) {
189 1
			return [];
190
		}
191
192
		// testing showed regex to be marginally faster than array / string functions over 100000 reps
193
		// it won't make a difference in real life and regex is easier to read.
194
		// <ts>/<viewtype>/<name/of/view.and.dots>.<type>
195 4
		if (!preg_match('#^/cache/([0-9]+)/([^/]+)/(.+)$#', $path, $matches)) {
196 3
			return [];
197
		}
198
199
		return [
200 1
			'ts' => $matches[1],
201 1
			'viewtype' => $matches[2],
202 1
			'view' => $matches[3],
203
		];
204
	}
205
206
	/**
207
	 * Is the view cacheable. Language views are handled specially.
208
	 *
209
	 * @param string $view View name
210
	 *
211
	 * @return bool
212
	 */
213
	protected function isCacheableView($view) {
214
		if (preg_match('~^languages/(.*)\.js$~', $view, $m)) {
215
			return in_array($m[1],  _elgg_services()->translator->getAllLanguageCodes());
216
		}
217
		return _elgg_services()->views->isCacheableView($view);
218
	}
219
220
	/**
221
	 * Sets cache headers
222
	 *
223
	 * @param string   $etag     ETag value
224
	 * @param Response $response the response to set the headers on
225
	 *
226
	 * @return void
227
	 */
228
	protected function sendCacheHeaders($etag, Response $response) {
229
		$response->setSharedMaxAge(86400 * 30 * 6);
230
		$response->setMaxAge(86400 * 30 * 6);
231
		$response->headers->set('ETag', $etag);
232
	}
233
234
	/**
235
	 * Set revalidate cache headers
236
	 *
237
	 * @param string   $etag     ETag value
238
	 * @param Response $response the response to set the headers on
239
	 *
240
	 * @return void
241
	 */
242
	protected function setRevalidateHeaders($etag, Response $response) {
243
		$response->headers->set('Cache-Control', "public, max-age=0, must-revalidate", true);
244
		$response->headers->set('ETag', $etag);
245
	}
246
247
	/**
248
	 * Send a 304 and exit() if the ETag matches the request
249
	 *
250
	 * @param string $etag ETag value
251
	 * @return bool
252
	 */
253
	protected function is304($etag) {
254
		$if_none_match = $this->request->headers->get('If-None-Match');
255
		if ($if_none_match === null) {
256
			return false;
257
		}
258
259
		// strip -gzip and leading /W
260
		$if_none_match = trim($if_none_match);
261
		if (0 === strpos($if_none_match, 'W/')) {
262
			$if_none_match = substr($if_none_match, 2);
263
		}
264
		$if_none_match = str_replace('-gzip', '', $if_none_match);
265
266
		return ($if_none_match === $etag);
267
	}
268
269
	/**
270
	 * Get the content type
271
	 *
272
	 * @param string $view The view name
273
	 *
274
	 * @return string|null
275
	 * @access private
276
	 */
277 1
	public function getContentType($view) {
278 1
		$extension = $this->getViewFileType($view);
279
		
280 1
		if (isset(self::$extensions[$extension])) {
281 1
			return self::$extensions[$extension];
282
		} else {
283
			return null;
284
		}
285
	}
286
	
287
	/**
288
	 * Returns the type of output expected from the view.
289
	 *
290
	 *  - view/name.extension returns "extension" if "extension" is valid
291
	 *  - css/view return "css"
292
	 *  - js/view return "js"
293
	 *  - Otherwise, returns "unknown"
294
	 *
295
	 * @param string $view The view name
296
	 * @return string
297
	 * @access private
298
	 */
299 2
	public function getViewFileType($view) {
300 2
		$extension = (new \SplFileInfo($view))->getExtension();
301 2
		$hasValidExtension = isset(self::$extensions[$extension]);
302
303 2
		if ($hasValidExtension) {
304 2
			return $extension;
305
		}
306
		
307
		if (preg_match('~(?:^|/)(css|js)(?:$|/)~', $view, $m)) {
308
			return $m[1];
309
		}
310
		
311
		return 'unknown';
312
	}
313
314
	/**
315
	 * Get the contents of a view for caching
316
	 *
317
	 * @param string $view     The view name
318
	 * @param string $viewtype The viewtype
319
	 * @return string|false
320
	 * @see CacheHandler::renderView()
321
	 */
322
	protected function getProcessedView($view, $viewtype) {
323
		$content = $this->renderView($view, $viewtype);
324
		if ($content === false) {
325
			return false;
326
		}
327
328
		if ($this->simplecache_enabled) {
329
			$hook_name = 'simplecache:generate';
330
		} else {
331
			$hook_name = 'cache:generate';
332
		}
333
		$hook_type = $this->getViewFileType($view);
334
		$hook_params = [
335
			'view' => $view,
336
			'viewtype' => $viewtype,
337
			'view_content' => $content,
338
		];
339
		return \_elgg_services()->hooks->trigger($hook_name, $hook_type, $hook_params, $content);
340
	}
341
342
	/**
343
	 * Render a view for caching. Language views are handled specially.
344
	 *
345
	 * @param string $view     The view name
346
	 * @param string $viewtype The viewtype
347
	 * @return string|false
348
	 */
349
	protected function renderView($view, $viewtype) {
350
		elgg_set_viewtype($viewtype);
351
352
		if ($viewtype === 'default' && preg_match("#^languages/(.*?)\\.js$#", $view, $matches)) {
353
			$view = "languages.js";
354
			$vars = ['language' => $matches[1]];
355
		} else {
356
			$vars = [];
357
		}
358
359
		if (!elgg_view_exists($view)) {
360
			return false;
361
		}
362
363
		// disable error reporting so we don't cache problems
364
		$this->config->debug = null;
365
366
		return elgg_view($view, $vars);
367
	}
368
369
	/**
370
	 * Send an error message to requestor
371
	 *
372
	 * @param string $msg Optional message text
373
	 * @return Response
374
	 */
375
	protected function send403($msg = 'Cache error: bad request') {
376
		return Response::create($msg, 403);
377
	}
378
}
379
380