Completed
Push — master ( 658f80...3de8bf )
by Steve
20:06 queued 10:55
created

CacheHandler   A

Complexity

Total Complexity 34

Size/Duplication

Total Lines 339
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 6

Test Coverage

Coverage 12.2%

Importance

Changes 0
Metric Value
dl 0
loc 339
ccs 15
cts 123
cp 0.122
rs 9.2
c 0
b 0
f 0
wmc 34
lcom 1
cbo 6

12 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 5 1
C handleRequest() 0 82 10
B parsePath() 0 23 4
A isCacheableView() 0 6 2
A sendCacheHeaders() 0 6 1
A sendRevalidateHeaders() 0 6 1
A handle304() 0 12 3
A getContentType() 0 9 2
A getViewFileType() 0 14 3
A getProcessedView() 0 16 2
A renderView() 0 19 4
A send403() 0 5 1
1
<?php
2
namespace Elgg\Application;
3
4
use Elgg\Application;
5
use Elgg\Config;
6
7
8
/**
9
 * Simplecache handler
10
 *
11
 * @access private
12
 *
13
 * @package Elgg.Core
14
 */
15
class CacheHandler {
16
	
17
	public static $extensions = [
18
		'bmp' => "image/bmp",
19
		'css' => "text/css",
20
		'gif' => "image/gif",
21
		'html' => "text/html",
22
		'ico' => "image/x-icon",
23
		'jpeg' => "image/jpeg",
24
		'jpg' => "image/jpeg",
25
		'js' => "application/javascript",
26
		'png' => "image/png",
27
		'svg' => "image/svg+xml",
28
		'swf' => "application/x-shockwave-flash",
29
		'tiff' => "image/tiff",
30
		'webp' => "image/webp",
31
		'xml' => "text/xml",
32
		'eot' => "application/vnd.ms-fontobject",
33
		'ttf' => "application/font-ttf",
34
		'woff' => "application/font-woff",
35
		'woff2' => "application/font-woff2",
36
		'otf' => "application/font-otf",
37
	];
38
39
	public static $utf8_content_types = [
40
		"text/css",
41
		"text/html",
42
		"application/javascript",
43
		"image/svg+xml",
44
		"text/xml",
45
	];
46
47
	/** @var Application */
48
	private $application;
49
50
	/** @var Config */
51
	private $config;
52
53
	/** @var array */
54
	private $server_vars;
55
56
	/**
57
	 * Constructor
58
	 *
59
	 * @param Application $app         Elgg Application
60
	 * @param Config      $config      Elgg configuration
61
	 * @param array       $server_vars Server vars
62
	 */
63 6
	public function __construct(Application $app, Config $config, $server_vars) {
64 6
		$this->application = $app;
65 6
		$this->config = $config;
66 6
		$this->server_vars = $server_vars;
67 6
	}
68
69
	/**
70
	 * Handle a request for a cached view
71
	 *
72
	 * @param array $path URL path
73
	 * @return void
74
	 */
75
	public function handleRequest($path) {
76
		$config = $this->config;
77
		
78
		$request = $this->parsePath($path);
0 ignored issues
show
Documentation introduced by
$path is of type array, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

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