Test Failed
Push — master ( 8c47c2...3acf9f )
by Steve
12:37
created

CacheHandler   B

Complexity

Total Complexity 38

Size/Duplication

Total Lines 360
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 11

Test Coverage

Coverage 12.1%

Importance

Changes 0
Metric Value
dl 0
loc 360
ccs 15
cts 124
cp 0.121
rs 8.3999
c 0
b 0
f 0
wmc 38
lcom 1
cbo 11

12 Methods

Rating   Name   Duplication   Size   Complexity  
A sendCacheHeaders() 0 5 1
A __construct() 0 6 1
D handleRequest() 0 96 13
B parsePath() 0 23 4
A isCacheableView() 0 6 2
A setRevalidateHeaders() 0 4 1
A is304() 0 15 3
A getContentType() 0 9 2
A getViewFileType() 0 14 3
A getProcessedView() 0 19 3
A renderView() 0 19 4
A send403() 0 3 1
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 Application */
51
	private $application;
52
53
	/** @var Config */
54
	private $config;
55
56
	/** @var Request */
57
	private $request;
58
59
	/**
60
	 * Constructor
61
	 *
62
	 * @param Application $app                 Elgg Application
63
	 * @param Config      $config              Elgg configuration
64
	 * @param Request     $request             HTTP request
65 6
	 * @param bool        $simplecache_enabled Is the simplecache enabled?
66 6
	 */
67 6
	public function __construct(Application $app, Config $config, Request $request, $simplecache_enabled) {
0 ignored issues
show
Bug introduced by
You have injected the Request via parameter $request. This is generally not recommended as there might be multiple instances during a request cycle (f.e. when using sub-requests). Instead, it is recommended to inject the RequestStack and retrieve the current request each time you need it via getCurrentRequest().
Loading history...
68 6
		$this->application = $app;
69 6
		$this->config = $config;
70
		$this->request = $request;
71
		$this->simplecache_enabled = $simplecache_enabled;
0 ignored issues
show
Bug introduced by
The property simplecache_enabled does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
72
	}
73
74
	/**
75
	 * Handle a request for a cached view
76
	 *
77
	 * @param array $path URL path
0 ignored issues
show
Bug introduced by
There is no parameter named $path. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

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